diff --git a/libs/domains/service-terraform/data-access/src/index.ts b/libs/domains/service-terraform/data-access/src/index.ts index 863eb222c60..3dbf712ea26 100644 --- a/libs/domains/service-terraform/data-access/src/index.ts +++ b/libs/domains/service-terraform/data-access/src/index.ts @@ -1 +1,2 @@ export * from './lib/domains-service-terraform-data-access' +export * from './lib/terraform.interface' diff --git a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts index 24cec9ee590..36c7f00b155 100644 --- a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts +++ b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts @@ -1,14 +1,46 @@ import { createQueryKeys } from '@lukemorales/query-key-factory' -import { TerraformMainCallsApi } from 'qovery-typescript-axios' +import { type AxiosError } from 'axios' +import { TerraformMainCallsApi, TerraformResourcesApi, type TerraformResourcesResponse } from 'qovery-typescript-axios' +import { type TerraformResource } from './terraform.interface' -const terraformApi = new TerraformMainCallsApi() +const terraformMainCallsApi = new TerraformMainCallsApi() +const terraformResourcesApi = new TerraformResourcesApi() + +function transformApiResponse(response: TerraformResourcesResponse): TerraformResource[] { + return response.results.map((item) => { + const resourceType = item.resource_type || '' + const displayName = resourceType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + + return { + id: item.id, + resourceType, + name: item.name, + address: item.address, + provider: item.provider, + mode: item.mode, + attributes: item.attributes, + extractedAt: item.extracted_at || '', + displayName, + } as TerraformResource + }) +} export const serviceTerraform = createQueryKeys('serviceTerraform', { listAvailableVersions: () => ({ queryKey: ['listTerraformAvailableVersion'], async queryFn() { - const response = await terraformApi.listTerraformVersions() + const response = await terraformMainCallsApi.listTerraformVersions() return response.data.results }, }), + listResources: (terraformId: string) => ({ + queryKey: [terraformId, 'resources'], + async queryFn(): Promise { + const response = await terraformResourcesApi.getTerraformResources(terraformId) + return transformApiResponse(response.data) + }, + }), }) diff --git a/libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts b/libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts new file mode 100644 index 00000000000..d868dbe992d --- /dev/null +++ b/libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts @@ -0,0 +1,16 @@ +/** + * Terraform resource interfaces for displaying infrastructure resources + * created by Terraform deployments in the Qovery Console. + */ + +export interface TerraformResource { + id: string + resourceType: string + name: string + address: string + provider: string + mode: string + attributes: Record + extractedAt: string + displayName: string +} diff --git a/libs/domains/service-terraform/feature/src/index.ts b/libs/domains/service-terraform/feature/src/index.ts index 2b8b329b91e..01b865fff16 100644 --- a/libs/domains/service-terraform/feature/src/index.ts +++ b/libs/domains/service-terraform/feature/src/index.ts @@ -3,4 +3,7 @@ export * from './source-setting/source-setting' export * from './lib/terraform-variables-settings/terraform-variables-settings' export * from './lib/terraform-variables-settings/terraform-variables-context' export * from './lib/terraform-variables-settings/terraform-tfvars-popover/terraform-tfvars-popover' -export * from './lib/terraform-variables-settings/terraform-configuration-settings/terraform-configuration-settings' +export * from './lib/hooks/use-terraform-resources/use-terraform-resources' +export * from './lib/terraform-resources-section/terraform-resources-section' +export * from './lib/resource-details/resource-details' +export * from './lib/resource-tree-list/resource-tree-list' diff --git a/libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts b/libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts new file mode 100644 index 00000000000..db840bdd957 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseTerraformResourcesProps { + terraformId: string + enabled?: boolean +} + +export function useTerraformResources({ terraformId, enabled = true }: UseTerraformResourcesProps) { + return useQuery({ + ...queries.serviceTerraform.listResources(terraformId), + enabled: Boolean(terraformId) && enabled, + }) +} diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx new file mode 100644 index 00000000000..93c93a41fa1 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx @@ -0,0 +1,65 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' +import { ResourceDetails } from './resource-details' + +const mockResource = createMockTerraformResource({ + provider: 'provider["registry.terraform.io/hashicorp/aws"]', + attributes: { + id: 'my-app-bucket', + bucket: 'my-app-bucket', + region: 'us-east-1', + versioning: { enabled: true }, + }, +}) + +describe('ResourceDetails', () => { + it('should show empty state when no resource is selected', () => { + renderWithProviders() + + expect(screen.getByText('No resource selected')).toBeInTheDocument() + }) + + it('should display resource metadata when resource is provided', () => { + renderWithProviders() + + expect(screen.getByText('app_bucket')).toBeInTheDocument() + expect(screen.getByText('aws_s3_bucket')).toBeInTheDocument() + expect(screen.getByText('aws_s3_bucket.app_bucket')).toBeInTheDocument() + }) + + it('should display all attributes in table', () => { + renderWithProviders() + + // Check table structure + expect(screen.getByText('Key')).toBeInTheDocument() + expect(screen.getByText('Value')).toBeInTheDocument() + + // Check attribute keys are displayed + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Type')).toBeInTheDocument() + expect(screen.getByText('region')).toBeInTheDocument() + }) + + it('should convert non-string attributes to JSON', () => { + renderWithProviders() + + // The versioning object should be displayed as JSON string + expect(screen.getByText(/enabled/)).toBeInTheDocument() + }) + + it('should display extracted date in readable format', () => { + renderWithProviders() + + expect(screen.getByText(/2025/)).toBeInTheDocument() + }) + + it('should render table with correct structure', () => { + renderWithProviders() + + // Verify table headers + const headers = screen.getAllByRole('columnheader') + expect(headers).toHaveLength(2) + expect(headers[0]).toHaveTextContent('Key') + expect(headers[1]).toHaveTextContent('Value') + }) +}) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx new file mode 100644 index 00000000000..ad9b90f17cb --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx @@ -0,0 +1,83 @@ +import { type ReactElement, useState } from 'react' +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' +import { CopyToClipboardButtonIcon, Icon, TablePrimitives } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' + +const { Table } = TablePrimitives + +export interface ResourceDetailsProps { + resource: TerraformResource | null +} + +interface TableRowData { + key: string + value: string +} + +export function ResourceDetails({ resource }: ResourceDetailsProps): ReactElement { + const [hoveredIndex, setHoveredIndex] = useState(null) + + if (!resource) { + return ( +
+ +

No resource selected

+

Select a resource from the list to view details.

+
+ ) + } + + const extractedAtDate = new Date(resource.extractedAt).toLocaleString() + + const tableData: TableRowData[] = [ + { key: 'Name', value: resource.name }, + { key: 'Type', value: resource.resourceType }, + { key: 'Address', value: resource.address }, + { key: 'Provider', value: resource.provider }, + { key: 'Mode', value: resource.mode }, + ...Object.entries(resource.attributes).map(([key, val]) => ({ + key, + value: typeof val === 'string' ? val : JSON.stringify(val), + })), + { key: 'Extracted At', value: extractedAtDate }, + ] + + return ( +
+
+ + + + + Key + + Value + + + + {tableData.map((row, index) => ( + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > + {row.key} + + {row.value} + {hoveredIndex === index && ( + + )} + + + ))} + + +
+
+ ) +} diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx new file mode 100644 index 00000000000..c5814653df1 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx @@ -0,0 +1,192 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' +import { ResourceTreeList } from './resource-tree-list' + +const mockResources = [ + createMockTerraformResource({ + id: 'res-1', + name: 'app_bucket', + provider: 'aws', + }), + createMockTerraformResource({ + 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({ + 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' }, + }), +] + +describe('ResourceTreeList', () => { + const mockOnSelectResource = jest.fn() + + beforeEach(() => { + mockOnSelectResource.mockClear() + }) + + it('should render empty state when no resources', () => { + renderWithProviders( + + ) + + expect(screen.getByText('No result for this search')).toBeInTheDocument() + }) + + it('should group resources by type', () => { + renderWithProviders( + + ) + + // 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', () => { + renderWithProviders( + + ) + + const selectedButton = screen.getByRole('button', { name: /app_bucket/ }) + expect(selectedButton).toHaveClass('bg-brand-50') + }) + + it('should call onSelectResource when clicking a resource', async () => { + const { userEvent } = renderWithProviders( + + ) + + const resourceButton = screen.getByRole('button', { name: /app_bucket/ }) + await userEvent.click(resourceButton) + + expect(mockOnSelectResource).toHaveBeenCalledWith('res-1') + }) + + it('should highlight matching resources 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-250') + }) + + it('should highlight matching 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-250') + }) + + it('should highlight matching 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-250') + }) + + it('should highlight matching 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-250') + }) + + it('should show no results message when search returns nothing', () => { + renderWithProviders( + + ) + + expect(screen.getByText('No resources match')).toBeInTheDocument() + }) + + it('should sort resources by name within each group', () => { + renderWithProviders( + + ) + + // 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/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx new file mode 100644 index 00000000000..2bccc1177cb --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx @@ -0,0 +1,131 @@ +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' + +export interface ResourceTreeListProps { + resources: TerraformResource[] + selectedResourceId: string | null + onSelectResource: (resourceId: string) => void + searchQuery: string +} + +interface GroupedResources { + resourceType: string + displayName: string + resources: TerraformResource[] +} + +function groupResourcesByType(resources: TerraformResource[]): GroupedResources[] { + const groups = new Map() + + for (const resource of resources) { + const list = groups.get(resource.resourceType) ?? [] + list.push(resource) + groups.set(resource.resourceType, list) + } + + return Array.from(groups.entries()) + .map(([type, groupItems]) => ({ + resourceType: type, + displayName: groupItems[0].displayName, + resources: groupItems.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) +} + +export function ResourceTreeList({ + resources, + selectedResourceId, + onSelectResource, + searchQuery, +}: 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)])) + }, [resources, searchQuery]) + + const groupedResources = useMemo(() => { + return groupResourcesByType(resources) + }, [resources]) + + useEffect(() => { + if (expandedGroups.length === 0 && groupedResources.length > 0) { + setExpandedGroups(groupedResources.map((group) => group.resourceType)) + } + }, [expandedGroups.length, groupedResources]) + + const hasMatches = Array.from(resourceMatchMap.values()).some((match) => match) + + if (resources.length === 0) { + return ( +
+ +

No result for this search

+
+ ) + } + + if (searchQuery && !hasMatches) { + return ( +
+ +

No resources match

+

No resources found for ${searchQuery}

+
+ ) + } + + return ( +
+ + {groupedResources.map((group) => ( + + + + {group.displayName} + ({group.resources.length}) + + + +
    + {group.resources.map((resource) => { + const matches = resourceMatchMap.get(resource.id) ?? true + const isSelected = selectedResourceId === resource.id + + function getButtonClassName(): string { + const base = + 'w-full cursor-pointer rounded h-8 px-2 gap-2 text-sm transition-colors flex items-center mb-1' + if (isSelected) { + return `${base} bg-brand-50 text-brand-500` + } + if (matches) { + return `${base} text-neutral-350 hover:bg-neutral-100` + } + return `${base} text-neutral-250 hover:bg-neutral-100` + } + + return ( +
  • + +
  • + ) + })} +
+
+
+ ))} +
+
+ ) +} diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx new file mode 100644 index 00000000000..f2b513cad39 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx @@ -0,0 +1,216 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import * as useTerraformResourcesModule from '../hooks/use-terraform-resources/use-terraform-resources' +import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' +import { TerraformResourcesSection } from './terraform-resources-section' + +jest.mock('../hooks/use-terraform-resources/use-terraform-resources') + +const mockUseTerraformResources = jest.spyOn(useTerraformResourcesModule, 'useTerraformResources') + +describe('TerraformResourcesSection', () => { + const terraformId = 'test-terraform-id' + const mockResource = createMockTerraformResource({ + id: '1', + resourceType: 'aws_instance', + name: 'web_server', + address: 'aws_instance.web_server', + displayName: 'EC2 Instance', + attributes: { instance_type: 't3.micro' }, + extractedAt: '2024-01-14T10:00:00Z', + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should render loading state', () => { + mockUseTerraformResources.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) + + it('should render error state with message', () => { + const errorMessage = 'Failed to fetch resources' + mockUseTerraformResources.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error(errorMessage), + } as any) + + renderWithProviders() + + expect(screen.getByText(/Failed to load terraform resources/i)).toBeInTheDocument() + expect(screen.getByText(errorMessage)).toBeInTheDocument() + }) + + it('should render empty state when no resources', () => { + mockUseTerraformResources.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText(/No infrastructure resources found/i)).toBeInTheDocument() + }) + + it('should render resources with tree list and details', () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + // Resource should be shown in tree list (may appear in both tree and details) + expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) + expect(screen.getByText('EC2 Instance')).toBeInTheDocument() + }) + + it('should render split panel with resources', () => { + const resources = [mockResource] + mockUseTerraformResources.mockReturnValue({ + data: resources, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText('EC2 Instance')).toBeInTheDocument() + expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) + }) + + it('should select first resource on load', () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText('aws_instance.web_server')).toBeInTheDocument() + }) + + it('should style non-matching resources differently when searching', async () => { + const resources = [ + mockResource, + createMockTerraformResource({ + id: '2', + name: 'data_bucket', + resourceType: 'aws_s3_bucket', + displayName: 'S3 Bucket', + address: 'aws_s3_bucket.data_bucket', + }), + ] + + mockUseTerraformResources.mockReturnValue({ + data: resources, + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + const searchInput = screen.getByPlaceholderText(/Search resources/i) + await userEvent.type(searchInput, 'bucket') + + // Both resources are visible in tree + expect(screen.getAllByText('data_bucket').length).toBeGreaterThan(0) + expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) + }) + + it('should show empty search state when no matches', async () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + const searchInput = screen.getByPlaceholderText(/Search resources/i) + await userEvent.type(searchInput, 'nonexistent') + + expect(screen.getByText('No resources match')).toBeInTheDocument() + }) + + it('should display multiple resources', () => { + const resources = [ + mockResource, + createMockTerraformResource({ + id: '2', + name: 'db_instance', + resourceType: 'aws_db_instance', + displayName: 'RDS Instance', + }), + ] + + mockUseTerraformResources.mockReturnValue({ + data: resources, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText('EC2 Instance')).toBeInTheDocument() + expect(screen.getByText('RDS Instance')).toBeInTheDocument() + }) + + it('should show clear search button when search query is entered', async () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + // Initially button should not be visible + expect(screen.queryByTitle(/Clear search/i)).not.toBeInTheDocument() + + // Type in search field + const searchInput = screen.getByPlaceholderText(/Search resources/i) + await userEvent.type(searchInput, 'test') + + // Clear button should now be visible + const clearButton = screen.getByTitle(/Clear search/i) + expect(clearButton).toBeInTheDocument() + expect(clearButton).toBeVisible() + }) + + it('should clear search when clicking the clear button', async () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + // Type in search field + const searchInput = screen.getByPlaceholderText(/Search resources/i) as HTMLInputElement + await userEvent.type(searchInput, 'test') + expect(searchInput.value).toBe('test') + + // Click clear button + const clearButton = screen.getByTitle(/Clear search/i) + await userEvent.click(clearButton) + + // Search input should be cleared + expect(searchInput.value).toBe('') + + // Clear button should not be visible anymore + expect(screen.queryByTitle(/Clear search/i)).not.toBeInTheDocument() + }) +}) diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx new file mode 100644 index 00000000000..8f90eaeb9a0 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx @@ -0,0 +1,127 @@ +import { type ReactElement, useEffect, useMemo, useState } from 'react' +import { Icon, InputTextSmall, LoaderSpinner } from '@qovery/shared/ui' +import { useTerraformResources } from '../hooks/use-terraform-resources/use-terraform-resources' +import { ResourceDetails } from '../resource-details/resource-details' +import { ResourceTreeList } from '../resource-tree-list/resource-tree-list' +import { matchesSearch } from '../utils/matches-search' + +export interface TerraformResourcesSectionProps { + terraformId: string +} + +export function TerraformResourcesSection({ terraformId }: TerraformResourcesSectionProps): ReactElement { + const [searchQuery, setSearchQuery] = useState('') + const [selectedResourceId, setSelectedResourceId] = useState(null) + + const { data, isLoading, error } = useTerraformResources({ terraformId }) + + // Auto-select first resource on load; re-select on search changes to keep selection in sync + useEffect(() => { + if (!data?.length) return + + if (!selectedResourceId) { + setSelectedResourceId(data[0].id) + return + } + + if (searchQuery) { + const selectedResource = data.find((r) => r.id === selectedResourceId) + if (!selectedResource || !matchesSearch(selectedResource, searchQuery)) { + const firstMatch = data.find((r) => matchesSearch(r, searchQuery)) + if (firstMatch) { + setSelectedResourceId(firstMatch.id) + } + } + } + }, [data, searchQuery, selectedResourceId]) + + // Get the selected resource (from all resources, not just filtered) + const selectedResource = useMemo( + () => data?.find((r) => r.id === selectedResourceId) ?? null, + [data, selectedResourceId] + ) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ +
+

Failed to load terraform resources

+

{error instanceof Error ? error.message : 'An error occurred'}

+
+
+ ) + } + + // Empty state (no resources at all) + if (!data || data.length === 0) { + return ( +
+ +

No infrastructure resources found

+

+ Terraform resources will appear here after your first successful deployment. +

+
+ ) + } + + return ( +
+ {/* Split panel: Tree list (with search) and Details */} +
+ {/* Left panel: Search + Resource tree list */} +
+ {/* Search bar */} +
+
+ setSearchQuery(e.target.value)} + placeholder="Search resources..." + className="w-full" + inputClassName="pr-8" + /> + {searchQuery && ( + + )} +
+
+ + {/* Tree list */} +
+ +
+
+ + {/* Right panel: Resource details */} +
+ +
+
+
+ ) +} diff --git a/libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts b/libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts new file mode 100644 index 00000000000..bad0aec5b28 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts @@ -0,0 +1,46 @@ +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' + +export function createMockTerraformResource(overrides: Partial = {}): TerraformResource { + return { + id: 'res-1', + resourceType: 'aws_s3_bucket', + name: 'app_bucket', + address: 'aws_s3_bucket.app_bucket', + provider: 'registry.terraform.io/hashicorp/aws', + mode: 'managed', + displayName: 'S3 Bucket', + extractedAt: '2025-01-16T12:00:00Z', + attributes: { + id: 'my-app-bucket', + bucket: 'my-app-bucket', + region: 'us-east-1', + }, + ...overrides, + } +} + +export const mockS3BucketResource = createMockTerraformResource() + +export const mockRdsResource = createMockTerraformResource({ + id: 'res-2', + resourceType: 'aws_rds_instance', + name: 'app_db', + address: 'aws_rds_instance.app_db', + displayName: 'RDS Instance', + attributes: { + id: 'mydb', + engine: 'mysql', + db_name: 'appdb', + }, +}) + +export const mockEc2Resource = createMockTerraformResource({ + id: 'res-3', + resourceType: 'aws_instance', + name: 'web_server', + address: 'aws_instance.web_server', + displayName: 'EC2 Instance', + attributes: { + instance_type: 't3.micro', + }, +}) diff --git a/libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts b/libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts new file mode 100644 index 00000000000..45fd8d97458 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts @@ -0,0 +1,18 @@ +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' + +export function matchesSearch(resource: TerraformResource, 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((v) => String(v)) + if (attributeValues.some((val) => val.toLowerCase().includes(lowerQuery))) return true + + return false +} diff --git a/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap b/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap index 0834620731d..6392b1d11b0 100644 --- a/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap +++ b/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap @@ -4,10 +4,10 @@ exports[`OutputVariables when serviceType is JOB should match snapshot 1`] = `
@@ -97,10 +97,10 @@ exports[`OutputVariables when serviceType is TERRAFORM should match snapshot 1`]
diff --git a/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx b/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx index cfa00177340..743b3a28b07 100644 --- a/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx +++ b/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx @@ -1,4 +1,4 @@ -import { renderWithProviders } from '@qovery/shared/util-tests' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { OutputVariables } from './output-variables' jest.mock('../hooks/use-variables/use-variables', () => ({ @@ -632,6 +632,15 @@ describe('OutputVariables', () => { expect(baseElement).toBeTruthy() }) + it('should render empty state when no output variables are found', () => { + renderWithProviders() + + expect(screen.getByText('No output variables found')).toBeInTheDocument() + expect( + screen.getByText('Job output variables will appear here after your first successful deployment.') + ).toBeInTheDocument() + }) + describe('when serviceType is TERRAFORM', () => { it('should match snapshot', () => { const { baseElement } = renderWithProviders( diff --git a/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx b/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx index e8a47f0c22c..157bbd21388 100644 --- a/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx +++ b/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx @@ -101,12 +101,20 @@ export function OutputVariables({ serviceId, serviceType, className, ...props }: }) if (variables.length === 0) { - return null + return ( +
+ +

No output variables found

+

+ {scopeName} output variables will appear here after your first successful deployment. +

+
+ ) } return ( -
- +
+ {table.getHeaderGroups().map((headerGroup) => ( diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx index 74548511d93..53bbb25a8a9 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx @@ -3,7 +3,9 @@ import { PageGeneral } from './page-general' describe('General', () => { it('should render successfully', () => { - const { baseElement } = renderWithProviders() + const { baseElement } = renderWithProviders( + + ) expect(baseElement).toBeTruthy() }) }) diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.tsx index caf6b2218b4..b076e795e16 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.tsx @@ -1,12 +1,15 @@ import { motion } from 'framer-motion' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { EnableObservabilityModal } from '@qovery/domains/observability/feature' +import { TerraformResourcesSection } from '@qovery/domains/service-terraform/feature' import { type AnyService } from '@qovery/domains/services/data-access' import { PodStatusesCallout, PodsMetrics, ServiceDetails } from '@qovery/domains/services/feature' import { OutputVariables } from '@qovery/domains/variables/feature' -import { Button, ExternalLink, Icon, useModal } from '@qovery/shared/ui' +import { Button, ExternalLink, Icon, TabsPrimitives, useModal } from '@qovery/shared/ui' import { useLocalStorage } from '@qovery/shared/util-hooks' +const { Tabs } = TabsPrimitives + export interface PageGeneralProps { serviceId: string environmentId: string @@ -169,10 +172,10 @@ function ObservabilityCallout() { } export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }: PageGeneralProps) { - const isLifecycleJobOrTerraform = useMemo( - () => (service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE') || service?.serviceType === 'TERRAFORM', - [service] - ) + const [activeTab, setActiveTab] = useState('variables') + + const isLifecycleJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE', [service]) + const isTerraformService = useMemo(() => service?.serviceType === 'TERRAFORM', [service]) const isCronJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'CRON', [service]) return ( @@ -180,24 +183,44 @@ export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }:
{hasNoMetrics && } - - {isCronJob && ( -
- -

- The number of past Completed or Failed job execution retained in the history and their TTL can be - customized in the advanced settings. -

- - See documentation - -
- )} -
- {isLifecycleJobOrTerraform && } + {!isTerraformService && ( + + {isCronJob && ( +
+ +

+ The number of past Completed or Failed job execution retained in the history and their TTL can be + customized in the advanced settings. +

+ + See documentation + +
+ )} +
+ )} + {isLifecycleJob && } + {isTerraformService && ( + + + Output Variables + Infrastructure Resources + + + + + + + + + )}