Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/domains/service-terraform/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './lib/domains-service-terraform-data-access'
export * from './lib/terraform.interface'
Original file line number Diff line number Diff line change
@@ -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<TerraformResource[]> {
const response = await terraformResourcesApi.getTerraformResources(terraformId)
return transformApiResponse(response.data)
},
}),
})
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
extractedAt: string
displayName: string
}
5 changes: 4 additions & 1 deletion libs/domains/service-terraform/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
@@ -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,
})
}
Original file line number Diff line number Diff line change
@@ -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(<ResourceDetails resource={null} />)

expect(screen.getByText('No resource selected')).toBeInTheDocument()
})

it('should display resource metadata when resource is provided', () => {
renderWithProviders(<ResourceDetails resource={mockResource} />)

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(<ResourceDetails resource={mockResource} />)

// 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(<ResourceDetails resource={mockResource} />)

// The versioning object should be displayed as JSON string
expect(screen.getByText(/enabled/)).toBeInTheDocument()
})

it('should display extracted date in readable format', () => {
renderWithProviders(<ResourceDetails resource={mockResource} />)

expect(screen.getByText(/2025/)).toBeInTheDocument()
})

it('should render table with correct structure', () => {
renderWithProviders(<ResourceDetails resource={mockResource} />)

// Verify table headers
const headers = screen.getAllByRole('columnheader')
expect(headers).toHaveLength(2)
expect(headers[0]).toHaveTextContent('Key')
expect(headers[1]).toHaveTextContent('Value')
})
})
Original file line number Diff line number Diff line change
@@ -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<number | null>(null)

if (!resource) {
return (
<div className="px-3 py-8 text-center">
<Icon iconName="wave-pulse" className="text-neutral-350" />
<p className="mt-1 text-xs font-medium text-neutral-350">No resource selected</p>
<p className="mt-1 text-xs text-neutral-350">Select a resource from the list to view details.</p>
</div>
)
}

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 (
<div className="flex flex-col overflow-y-auto">
<div className="overflow-hidden">
<Table.Root className="w-full text-ssm">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell className="w-2/4 border-r border-neutral-200 font-medium">
Key
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="w-2/4 font-medium">Value</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{tableData.map((row, index) => (
<Table.Row
key={index}
className="h-12"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<Table.Cell className="w-1/2 border-r border-neutral-200 text-neutral-350">{row.key}</Table.Cell>
<Table.Cell className={twMerge('w-1/2 text-neutral-400', hoveredIndex === index && 'group')}>
<span className="truncate break-all text-ssm">{row.value}</span>
{hoveredIndex === index && (
<CopyToClipboardButtonIcon
content={row.value}
tooltipContent="Copy to clipboard"
className="ml-1.5 text-xs text-neutral-350 hover:text-brand-500"
/>
)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</div>
</div>
)
}
Loading