diff --git a/apps/app-frontend/src/shell/AppShell.test.tsx b/apps/app-frontend/src/shell/AppShell.test.tsx new file mode 100644 index 00000000..3ae5f9c6 --- /dev/null +++ b/apps/app-frontend/src/shell/AppShell.test.tsx @@ -0,0 +1,45 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionProvider } from '@osac/ui-components/hooks/use-session'; +import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders'; + +vi.mock('./StorageRoutes', () => ({ + StorageRoutes: () =>

Storage routes

, +})); + +import { AppShell } from './AppShell'; + +const renderAppShell = (entry: string) => + renderWithProviders( + + + , + { + apiFixtures: { privateInstanceTypes: [] }, + routerEntries: [entry], + }, + ); + +describe('AppShell', () => { + it('renders the storage route through the admin shell', () => { + renderAppShell('/admin/infrastructure/storage/backends'); + + expect(screen.getByRole('heading', { name: 'Storage routes' })).toBeInTheDocument(); + }); + + it('renders the instance type list route through the admin shell', async () => { + renderAppShell('/admin/infrastructure/instance-types'); + + expect(screen.getByRole('heading', { name: 'Instance types' })).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('No instance types yet.')).toBeInTheDocument(); + }); + }); + + it('renders the instance type create shell through the admin shell', () => { + renderAppShell('/admin/infrastructure/instance-types/create'); + + expect(screen.getByRole('heading', { name: 'Create instance type' })).toBeInTheDocument(); + }); +}); diff --git a/apps/app-frontend/src/shell/AppShell.tsx b/apps/app-frontend/src/shell/AppShell.tsx index bad4b903..119954b2 100644 --- a/apps/app-frontend/src/shell/AppShell.tsx +++ b/apps/app-frontend/src/shell/AppShell.tsx @@ -16,6 +16,7 @@ import { ClusterRoutes } from '@osac/ui-components/pages/tenant/ClusterRoutes'; import { VmCreatePage } from '@osac/ui-components/pages/tenant/VmCreatePage'; import { VmListPage } from '@osac/ui-components/pages/tenant/VmListPage'; +import { InstanceTypeRoutes } from './InstanceTypeRoutes'; import { ShellMasthead } from './ShellMasthead'; import { defaultRouteForRole } from './shellRoutes'; import { ShellSidebar } from './ShellSidebar'; @@ -49,13 +50,21 @@ export const AppShell = ({ logout }: { logout: () => Promise }) => { } /> } /> + + + + } + /> ({ + default: () =>

Instance types

, +})); + +vi.mock('@osac/ui-components/components/InstanceType/AdminInstanceTypeCreatePage', () => ({ + default: () =>

Create instance type

, +})); + +import { InstanceTypeRoutes } from './InstanceTypeRoutes'; + +const renderRoutes = (initialEntry: string) => ( + + + } /> + + +); + +describe('InstanceTypeRoutes', () => { + it('renders the list page on the index route', () => { + render(renderRoutes('/admin/infrastructure/instance-types')); + + expect(screen.getByRole('heading', { name: 'Instance types' })).toBeInTheDocument(); + }); + + it('renders the create page shell on the create route', () => { + render(renderRoutes('/admin/infrastructure/instance-types/create')); + + expect(screen.getByRole('heading', { name: 'Create instance type' })).toBeInTheDocument(); + }); +}); diff --git a/apps/app-frontend/src/shell/InstanceTypeRoutes.tsx b/apps/app-frontend/src/shell/InstanceTypeRoutes.tsx new file mode 100644 index 00000000..a4116453 --- /dev/null +++ b/apps/app-frontend/src/shell/InstanceTypeRoutes.tsx @@ -0,0 +1,11 @@ +import { Route, Routes } from 'react-router-dom'; + +import AdminInstanceTypeCreatePage from '@osac/ui-components/components/InstanceType/AdminInstanceTypeCreatePage'; +import AdminInstanceTypeListPage from '@osac/ui-components/components/InstanceType/AdminInstanceTypeListPage'; + +export const InstanceTypeRoutes = () => ( + + } /> + } /> + +); diff --git a/apps/app-frontend/src/shell/StorageRoutes.test.tsx b/apps/app-frontend/src/shell/StorageRoutes.test.tsx index d1911a6b..0775d7ee 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.test.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.test.tsx @@ -9,32 +9,32 @@ import { StorageRoutes } from './StorageRoutes'; const renderAt = (path: string) => renderWithProviders( - } /> + } /> , { routerEntries: [path] }, ); describe('StorageRoutes', () => { - it('redirects the bare /admin/storage path to the Backends tab', () => { - renderAt('/admin/storage'); + it('redirects the bare /admin/infrastructure/storage path to the Backends tab', () => { + renderAt('/admin/infrastructure/storage'); expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends'); }); it('renders a placeholder for backends/create', () => { - renderAt('/admin/storage/backends/create'); + renderAt('/admin/infrastructure/storage/backends/create'); expect(screen.getByText('Create storage backend')).toBeInTheDocument(); }); it('renders a placeholder for backends/:id/edit', () => { - renderAt('/admin/storage/backends/abc-123/edit'); + renderAt('/admin/infrastructure/storage/backends/abc-123/edit'); expect(screen.getByText('Edit storage backend')).toBeInTheDocument(); }); - it('renders the Tiers tab at /admin/storage/tiers', () => { - renderAt('/admin/storage/tiers'); + it('renders the Tiers tab at /admin/infrastructure/storage/tiers', () => { + renderAt('/admin/infrastructure/storage/tiers'); expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers'); }); diff --git a/apps/app-frontend/src/shell/shellNav.test.ts b/apps/app-frontend/src/shell/shellNav.test.ts index 9cf96eee..0b52c53e 100644 --- a/apps/app-frontend/src/shell/shellNav.test.ts +++ b/apps/app-frontend/src/shell/shellNav.test.ts @@ -43,13 +43,35 @@ describe('navRowsForRole', () => { } }); - it('includes Tenants and Storage under Administration for admin role', () => { + it('includes only Tenants under Administration for admin role', () => { expect(findSection('admin', 'nav-administration')?.children).toEqual([ { id: 'tenant', label: 'Tenants', path: '/admin/tenants' }, - { id: 'storage', label: 'Storage', path: '/admin/storage' }, ]); }); + it('Infrastructure section shows up only for admin role and contains storage and instance types', () => { + expect(findSection('admin', 'nav-infrastructure')).toEqual({ + kind: 'section', + sectionId: 'nav-infrastructure', + label: 'Infrastructure', + children: [ + { + id: 'storage', + label: 'Storage', + path: '/admin/infrastructure/storage', + }, + { + id: 'instance-types', + label: 'Instance types', + path: '/admin/infrastructure/instance-types', + }, + ], + }); + for (const role of ['tenant-user', 'tenant-admin', 'tenant-idp-manager'] as UserRole[]) { + expect(findSection(role, 'nav-infrastructure')).toBeUndefined(); + } + }); + it('IDP administration shows up only for idp manager', () => { expect(findSection('tenant-idp-manager', 'nav-tenant-administration')).toBeDefined(); for (const role of ['tenant-user', 'tenant-admin', 'admin'] as UserRole[]) { diff --git a/apps/app-frontend/src/shell/shellNav.ts b/apps/app-frontend/src/shell/shellNav.ts index 90e87d35..6ab8e212 100644 --- a/apps/app-frontend/src/shell/shellNav.ts +++ b/apps/app-frontend/src/shell/shellNav.ts @@ -26,9 +26,23 @@ const getAdminNav = (t: TFunction): NavSection[] => [ kind: 'section', sectionId: 'nav-administration', label: t('Administration'), + children: [{ id: 'tenant', label: t('Tenants'), path: '/admin/tenants' }], + }, + { + kind: 'section', + sectionId: 'nav-infrastructure', + label: t('Infrastructure'), children: [ - { id: 'tenant', label: t('Tenants'), path: '/admin/tenants' }, - { id: 'storage', label: t('Storage'), path: '/admin/storage' }, + { + id: 'storage', + label: t('Storage'), + path: '/admin/infrastructure/storage', + }, + { + id: 'instance-types', + label: t('Instance types'), + path: '/admin/infrastructure/instance-types', + }, ], }, ...getBaseNav(t), diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 4e223a3d..60b16771 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -1,6 +1,7 @@ { "Actions": "Actions", "Actions for {{name}}": "Actions for {{name}}", + "Active": "Active", "Add": "Add", "Add domain": "Add domain", "Add node set": "Add node set", @@ -118,12 +119,15 @@ "Copy": "Copy", "Could not load host types": "Could not load host types", "Could not load instance types": "Could not load instance types", + "CPU cores": "CPU cores", "Create": "Create", + "Create an instance type to start defining provider-managed sizes.": "Create an instance type to start defining provider-managed sizes.", "Create cluster": "Create cluster", "Create cluster wizard": "Create cluster wizard", "Create identity provider": "Create identity provider", "Create Identity provider": "Create Identity provider", "Create Identity provider steps": "Create Identity provider steps", + "Create instance type": "Create instance type", "Create security group": "Create security group", "Create storage backend": "Create storage backend", "Create subnet": "Create subnet", @@ -142,6 +146,7 @@ "Delete security group?": "Delete security group?", "Deleting": "Deleting", "deprecated": "deprecated", + "Deprecated": "Deprecated", "Description": "Description", "Destination CIDR": "Destination CIDR", "Details": "Details", @@ -224,7 +229,9 @@ "Identity providers": "Identity providers", "IdP manager": "IdP manager", "Inbound Rules": "Inbound Rules", + "Infrastructure": "Infrastructure", "Instance type": "Instance type", + "Instance types": "Instance types", "Internal IP": "Internal IP", "Invalid IPv4 CIDR format (e.g., 192.168.1.0/24)": "Invalid IPv4 CIDR format (e.g., 192.168.1.0/24)", "Invalid IPv4 CIDR notation": "Invalid IPv4 CIDR notation", @@ -240,15 +247,18 @@ "Issuer is required": "Issuer is required", "JWKS URL": "JWKS URL", "Keep editing": "Keep editing", + "Lifecycle state": "Lifecycle state", "Loading cluster password": "Loading cluster password", "Loading security groups...": "Loading security groups...", "Loading subnets...": "Loading subnets...", "Logout URL": "Logout URL", "Manage firewall rules for your virtual networks.": "Manage firewall rules for your virtual networks.", "Manage identity providers for your tenant.": "Manage identity providers for your tenant.", + "Manage provider-defined instance types for this cloud platform.": "Manage provider-defined instance types for this cloud platform.", "Manage storage backends and tiers for this cloud platform.": "Manage storage backends and tiers for this cloud platform.", "Manage tenants for this cloud platform.": "Manage tenants for this cloud platform.", "Manage virtual networks for your compute instances.": "Manage virtual networks for your compute instances.", + "Memory (GiB)": "Memory (GiB)", "Message": "Message", "Must be a valid domain (e.g. example.com)": "Must be a valid domain (e.g. example.com)", "Must be a valid URL (e.g. https://example.com)": "Must be a valid URL (e.g. https://example.com)", @@ -271,6 +281,7 @@ "No identity providers match your search.": "No identity providers match your search.", "No identity providers yet. Create one to get started.": "No identity providers yet. Create one to get started.", "No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.", + "No instance types yet.": "No instance types yet.", "No node sets added yet.": "No node sets added yet.", "No node sets configured.": "No node sets configured.", "No outbound rules yet. Add one to allow outgoing traffic.": "No outbound rules yet. Add one to allow outgoing traffic.", @@ -288,6 +299,7 @@ "Node Sets": "Node Sets", "Nodes": "Nodes", "Not defined": "Not defined", + "Obsolete": "Obsolete", "OIDC": "OIDC", "OIDC configuration": "OIDC configuration", "Open catalog item details for {{title}}": "Open catalog item details for {{title}}", diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index d7311c9a..2bf0463c 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -27,6 +27,7 @@ export type ApiRoute = | 'v1/external_ip_attachments' | 'v1/external_ip_pools' | 'v1/console_sessions' + | 'v1/private/instance_types' | 'v1/private/tenants' | 'v1/private/storage_backends' | 'v1/private/storage_tiers' diff --git a/libs/ui-components/src/api/v1/private/instance-type.test.ts b/libs/ui-components/src/api/v1/private/instance-type.test.ts new file mode 100644 index 00000000..28bb06a7 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/instance-type.test.ts @@ -0,0 +1,71 @@ +import React, { type ReactNode, createElement } from 'react'; +import { create } from '@bufbuild/protobuf'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { + InstanceTypeSchema, + InstanceTypeState, + type InstanceType as PrivateInstanceType, +} from '@osac/types/private'; + +import { useAdminInstanceTypes } from './instance-type'; +import { createMockConnectTransport } from '../../../test-utils/createMockConnectTransport'; +import { ApiProvider } from '../../api-context'; + +const makeInstanceType = ( + id: string, + state: InstanceTypeState = InstanceTypeState.ACTIVE, +): PrivateInstanceType => + create(InstanceTypeSchema, { + id, + metadata: { name: `instance-type-${id}` }, + spec: { + description: `${id} description`, + cores: 4, + memoryGib: 16, + state, + }, + }); + +const makeWrapper = (transport: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + ApiProvider, + { transport } as React.ComponentProps, + createElement(QueryClientProvider, { client: queryClient }, children), + ); + return { wrapper, queryClient }; +}; + +describe('useAdminInstanceTypes', () => { + it('returns all private instance type items from the list response', async () => { + const transport = createMockConnectTransport({ + privateInstanceTypes: [ + makeInstanceType('active-1', InstanceTypeState.ACTIVE), + makeInstanceType('deprecated-1', InstanceTypeState.DEPRECATED), + ], + }); + const { wrapper } = makeWrapper(transport); + const { result } = renderHook(() => useAdminInstanceTypes(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.map((item) => item.id)).toEqual(['active-1', 'deprecated-1']); + }); + + it('stores query results under the private instance type cache key', async () => { + const transport = createMockConnectTransport({ + privateInstanceTypes: [makeInstanceType('active-1')], + }); + const { wrapper, queryClient } = makeWrapper(transport); + const { result } = renderHook(() => useAdminInstanceTypes(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(queryClient.getQueryData(['v1/private/instance_types'])).toBeDefined(); + expect(queryClient.getQueryData(['v1/instance_types'])).toBeUndefined(); + }); +}); diff --git a/libs/ui-components/src/api/v1/private/instance-type.ts b/libs/ui-components/src/api/v1/private/instance-type.ts new file mode 100644 index 00000000..9d371922 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/instance-type.ts @@ -0,0 +1,14 @@ +import { InstanceTypes } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const useAdminInstanceTypes = (params: ListParams = {}) => { + const client = useApiFetch(InstanceTypes); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/instance_types', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + }); +}; diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeCreatePage.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeCreatePage.tsx new file mode 100644 index 00000000..4c983917 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeCreatePage.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom'; +import { + Breadcrumb, + BreadcrumbItem, + Button, + EmptyState, + EmptyStateBody, + PageSection, + Stack, +} from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +const AdminInstanceTypeCreatePage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + + + + + + + {t('Create')} + + + {t('This feature is coming soon.')} + + + + ); +}; + +export default AdminInstanceTypeCreatePage; diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx new file mode 100644 index 00000000..85b0e420 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx @@ -0,0 +1,158 @@ +import { Route, Routes } from 'react-router-dom'; +import { create } from '@bufbuild/protobuf'; +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + InstanceTypeSchema, + InstanceTypeState, + type InstanceType as PrivateInstanceType, +} from '@osac/types/private'; +import { mockQueryResult } from '@osac/ui-components/test-utils/query'; + +import AdminInstanceTypeListPage from './AdminInstanceTypeListPage'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +vi.mock('@osac/ui-components/api/v1/private/instance-type', () => ({ + useAdminInstanceTypes: vi.fn(), +})); + +const { useAdminInstanceTypes } = await import('@osac/ui-components/api/v1/private/instance-type'); + +const longDescription = + 'A provider-curated general-purpose instance type for sustained workloads that need predictable CPU and memory capacity, room for sidecar processes, and enough headroom for bursty background tasks without immediately resizing the virtual machine.'; + +const makeInstanceType = ( + id: string, + state: InstanceTypeState, + description = `${id} description`, +): PrivateInstanceType => + create(InstanceTypeSchema, { + id, + metadata: { + name: `instance-type-${id}`, + creationTimestamp: { seconds: BigInt(1717000000), nanos: 0 }, + }, + spec: { + cores: 4, + memoryGib: 16, + description, + state, + }, + }); + +const renderPage = () => renderWithProviders(); + +const renderPageWithCreateRoute = () => + renderWithProviders( + + } /> + Create instance type page} + /> + , + { routerEntries: ['/admin/infrastructure/instance-types'] }, + ); + +describe('AdminInstanceTypeListPage', () => { + it('renders the required columns and lifecycle labels for populated data', () => { + vi.mocked(useAdminInstanceTypes).mockReturnValue( + mockQueryResult({ + data: [ + makeInstanceType('active-1', InstanceTypeState.ACTIVE), + makeInstanceType('deprecated-1', InstanceTypeState.DEPRECATED), + makeInstanceType('obsolete-1', InstanceTypeState.OBSOLETE), + makeInstanceType('long-description-1', InstanceTypeState.ACTIVE, longDescription), + ], + }), + ); + + renderPage(); + + expect(screen.getByRole('heading', { name: 'Instance types' })).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([ + 'Name', + 'Lifecycle state', + 'CPU cores', + 'Memory (GiB)', + 'Description', + 'Created', + ]); + expect(screen.getByText('instance-type-active-1')).toBeInTheDocument(); + expect(screen.getByText('active-1 description')).toBeInTheDocument(); + const truncatedDescription = Array.from( + document.querySelectorAll('.pf-v6-c-truncate__text'), + ).find((element) => element.textContent?.startsWith('A provider-curated general-purpose')); + expect(truncatedDescription).toBeDefined(); + expect(truncatedDescription).not.toBeNull(); + expect(truncatedDescription?.textContent).toContain('A provider-curated'); + expect(truncatedDescription?.closest('.pf-v6-c-truncate')).toHaveClass('pf-m-fixed'); + expect( + Array.from(document.querySelectorAll('.pf-v6-c-truncate__omission')).some( + (element) => element.textContent === '...', + ), + ).toBe(true); + expect(screen.getAllByText('Active')).toHaveLength(2); + expect(screen.getByText('Deprecated')).toBeInTheDocument(); + expect(screen.getByText('Obsolete')).toBeInTheDocument(); + }); + + it('shows a loading spinner while the query is in flight', () => { + vi.mocked(useAdminInstanceTypes).mockReturnValue( + mockQueryResult({ + data: undefined, + isLoading: true, + }), + ); + + renderPage(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows the empty state when no instance types are returned', () => { + vi.mocked(useAdminInstanceTypes).mockReturnValue( + mockQueryResult({ + data: [], + }), + ); + + renderPage(); + + expect(screen.getByText('No instance types yet.')).toBeInTheDocument(); + expect( + screen.getByText('Create an instance type to start defining provider-managed sizes.'), + ).toBeInTheDocument(); + expect(screen.getByRole('grid', { name: 'Instance types' })).toBeInTheDocument(); + }); + + it('uses the page-level error state when the query fails', () => { + vi.mocked(useAdminInstanceTypes).mockReturnValue( + mockQueryResult({ + data: [], + error: new Error('Private instance types unavailable'), + }), + ); + + renderPage(); + + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + expect(screen.getByText('Private instance types unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('navigates to the create route when the create button is clicked', async () => { + vi.mocked(useAdminInstanceTypes).mockReturnValue( + mockQueryResult({ + data: [], + }), + ); + + const { user } = renderPageWithCreateRoute(); + + await user.click(screen.getByRole('button', { name: 'Create instance type' })); + + expect(screen.getByRole('heading', { name: 'Create instance type page' })).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.tsx new file mode 100644 index 00000000..be36dc73 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; + +import AdminInstanceTypeTable from './AdminInstanceTypeTable'; +import { useAdminInstanceTypes } from '../../api/v1/private/instance-type'; +import { useTranslation } from '../../hooks/useTranslation'; +import ListPage from '../Page/ListPage'; +import ListPageBody from '../Page/ListPageBody'; + +const AdminInstanceTypeListPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data: instanceTypes = [], isLoading, error } = useAdminInstanceTypes(); + + return ( + navigate('/admin/infrastructure/instance-types/create')} + > + {t('Create instance type')} + + } + > + + + + + ); +}; + +export default AdminInstanceTypeListPage; diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx new file mode 100644 index 00000000..47831a1b --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx @@ -0,0 +1,94 @@ +import { Bullseye, EmptyState, EmptyStateBody, EmptyStateVariant } from '@patternfly/react-core'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import type { InstanceType as PrivateInstanceType } from '@osac/types/private'; + +import InstanceTypeLifecycleLabel from './InstanceTypeLifecycleLabel'; +import { useTranslation } from '../../hooks/useTranslation'; +import { Timestamp } from '../Primitives/Timestamp'; +import TruncatedText from '../Primitives/TruncatedText'; + +const INSTANCE_TYPE_DESCRIPTION_PREVIEW_LENGTH = 120; +const INSTANCE_TYPE_NAME_PREVIEW_LENGTH = 32; +const NAME_COLUMN_WIDTH = 15; +const LIFECYCLE_STATE_COLUMN_WIDTH = 10; +const CPU_CORES_COLUMN_WIDTH = 10; +const MEMORY_COLUMN_WIDTH = 10; +const DESCRIPTION_COLUMN_WIDTH = 40; +const CREATED_COLUMN_WIDTH = 15; +const EMPTY_STATE_COLUMN_SPAN = 6; + +interface AdminInstanceTypeTableProps { + instanceTypes: PrivateInstanceType[]; +} + +const AdminInstanceTypeTable = ({ instanceTypes }: AdminInstanceTypeTableProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + {instanceTypes.length === 0 ? ( + + + + ) : ( + instanceTypes.map((instanceType) => ( + + + + + + + + + )) + )} + +
{t('Name')}{t('Lifecycle state')}{t('CPU cores')}{t('Memory (GiB)')}{t('Description')}{t('Created')}
+ + + + {t('Create an instance type to start defining provider-managed sizes.')} + + + +
+ + + + + {instanceType.spec?.cores ?? '—'} + + {instanceType.spec?.memoryGib ?? '—'} + + + + +
+ ); +}; + +export default AdminInstanceTypeTable; diff --git a/libs/ui-components/src/components/InstanceType/InstanceTypeLifecycleLabel.test.tsx b/libs/ui-components/src/components/InstanceType/InstanceTypeLifecycleLabel.test.tsx new file mode 100644 index 00000000..452cb2e8 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/InstanceTypeLifecycleLabel.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { InstanceTypeState } from '@osac/types/private'; + +import InstanceTypeLifecycleLabel from './InstanceTypeLifecycleLabel'; + +const expectLabelColor = (text: string, colorClass?: string) => { + const label = screen.getByText(text).closest('.pf-v6-c-label'); + + expect(label).not.toBeNull(); + if (colorClass) { + expect(label).toHaveClass(colorClass); + return; + } + + expect(label).not.toHaveClass('pf-m-green'); + expect(label).not.toHaveClass('pf-m-orange'); +}; + +describe('InstanceTypeLifecycleLabel', () => { + it('renders active instance types in green', () => { + render(); + + expectLabelColor('Active', 'pf-m-green'); + }); + + it('renders deprecated instance types in orange', () => { + render(); + + expectLabelColor('Deprecated', 'pf-m-orange'); + }); + + it('renders obsolete instance types in grey', () => { + render(); + + expectLabelColor('Obsolete'); + }); + + it('falls back to unspecified when the state is missing', () => { + render(); + + expectLabelColor('Unspecified'); + }); + + it('falls back to unspecified when the state is not one of the known lifecycle states', () => { + render(); + + expectLabelColor('Unspecified'); + }); +}); diff --git a/libs/ui-components/src/components/InstanceType/InstanceTypeLifecycleLabel.tsx b/libs/ui-components/src/components/InstanceType/InstanceTypeLifecycleLabel.tsx new file mode 100644 index 00000000..5157ac62 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/InstanceTypeLifecycleLabel.tsx @@ -0,0 +1,37 @@ +import type { TFunction } from 'i18next'; + +import { InstanceTypeState } from '@osac/types/private'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { + ResourceLifecycleLabel, + ResourceLifecycleLabelProps, +} from '../Resource/ResourceLifecycleLabel'; + +export interface InstanceTypeLifecycleLabelProps { + state?: InstanceTypeState; +} + +const instanceTypeLifecycleMap = ( + t: TFunction, +): Record => ({ + [InstanceTypeState.ACTIVE]: { lifecycle: 'active', text: t('Active') }, + [InstanceTypeState.DEPRECATED]: { lifecycle: 'deprecated', text: t('Deprecated') }, + [InstanceTypeState.OBSOLETE]: { lifecycle: 'obsolete', text: t('Obsolete') }, + [InstanceTypeState.UNSPECIFIED]: { lifecycle: 'unspecified', text: t('Unspecified') }, +}); + +const InstanceTypeLifecycleLabel = ({ state }: InstanceTypeLifecycleLabelProps) => { + const { t } = useTranslation(); + + const lifecycleMap = instanceTypeLifecycleMap(t); + + const props = + state !== undefined + ? (lifecycleMap[state] ?? lifecycleMap[InstanceTypeState.UNSPECIFIED]) + : lifecycleMap[InstanceTypeState.UNSPECIFIED]; + + return ; +}; + +export default InstanceTypeLifecycleLabel; diff --git a/libs/ui-components/src/components/Primitives/TruncatedText.test.tsx b/libs/ui-components/src/components/Primitives/TruncatedText.test.tsx new file mode 100644 index 00000000..a977720e --- /dev/null +++ b/libs/ui-components/src/components/Primitives/TruncatedText.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import TruncatedText from './TruncatedText'; + +describe('TruncatedText', () => { + it('renders short content unmodified', () => { + render(); + + expect(screen.getByText('short text')).toBeInTheDocument(); + }); + + it('collapses internal whitespace and trims the content', () => { + render(); + + expect(screen.getByText('extra spaces here')).toBeInTheDocument(); + }); + + it('truncates content longer than maxCharsDisplayed with an omission marker', () => { + render(); + + const truncated = document.querySelector('.pf-v6-c-truncate__text'); + expect(truncated).not.toBeNull(); + expect(truncated?.textContent).toBe('a very lon'); + expect(document.querySelector('.pf-v6-c-truncate__omission')?.textContent).toBe('...'); + }); + + it('renders an em dash fallback when content is missing', () => { + render(); + + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('renders a custom fallback when content is missing', () => { + render(); + + expect(screen.getByText('No description')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/Primitives/TruncatedText.tsx b/libs/ui-components/src/components/Primitives/TruncatedText.tsx new file mode 100644 index 00000000..0536404f --- /dev/null +++ b/libs/ui-components/src/components/Primitives/TruncatedText.tsx @@ -0,0 +1,31 @@ +import { Truncate } from '@patternfly/react-core'; + +export interface TruncatedTextProps { + content?: string | null; + maxCharsDisplayed: number; + fallback?: string; + omissionContent?: string; +} + +const TruncatedText = ({ + content, + maxCharsDisplayed, + fallback = '—', + omissionContent = '...', +}: TruncatedTextProps) => { + const normalized = (content || '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return <>{fallback}; + } + + return ( + + ); +}; + +export default TruncatedText; diff --git a/libs/ui-components/src/components/Resource/ResourceLifecycleLabel.test.tsx b/libs/ui-components/src/components/Resource/ResourceLifecycleLabel.test.tsx new file mode 100644 index 00000000..388eacab --- /dev/null +++ b/libs/ui-components/src/components/Resource/ResourceLifecycleLabel.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { ResourceLifecycleLabel } from './ResourceLifecycleLabel'; + +describe('ResourceLifecycleLabel', () => { + it('renders active in green', () => { + render(); + + expect(screen.getByText('Active').closest('.pf-v6-c-label')).toHaveClass('pf-m-green'); + }); + + it('renders deprecated in orange', () => { + render(); + + expect(screen.getByText('Deprecated').closest('.pf-v6-c-label')).toHaveClass('pf-m-orange'); + }); + + it('renders obsolete in grey', () => { + render(); + + const label = screen.getByText('Obsolete').closest('.pf-v6-c-label'); + expect(label).not.toHaveClass('pf-m-green'); + expect(label).not.toHaveClass('pf-m-orange'); + }); +}); diff --git a/libs/ui-components/src/components/Resource/ResourceLifecycleLabel.tsx b/libs/ui-components/src/components/Resource/ResourceLifecycleLabel.tsx new file mode 100644 index 00000000..3c1715ac --- /dev/null +++ b/libs/ui-components/src/components/Resource/ResourceLifecycleLabel.tsx @@ -0,0 +1,21 @@ +import { Label } from '@patternfly/react-core'; + +export type LifecycleKind = 'active' | 'deprecated' | 'obsolete' | 'unspecified'; + +type LifecycleLabelColor = 'green' | 'orange' | 'grey'; + +const LIFECYCLE_COLOR: Record = { + active: 'green', + deprecated: 'orange', + obsolete: 'grey', + unspecified: 'grey', +}; + +export interface ResourceLifecycleLabelProps { + lifecycle: LifecycleKind; + text: string; +} + +export const ResourceLifecycleLabel = ({ lifecycle, text }: ResourceLifecycleLabelProps) => { + return ; +}; diff --git a/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx b/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx index d97263c6..7f7271c7 100644 --- a/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx +++ b/libs/ui-components/src/pages/admin/StorageManagementPage.test.tsx @@ -9,12 +9,15 @@ const renderPage = (activeTab: 'backends' | 'tiers') => renderWithProviders( } /> - } /> + } + /> , - { routerEntries: [`/admin/storage/${activeTab}`] }, + { routerEntries: [`/admin/infrastructure/storage/${activeTab}`] }, ); describe('StorageManagementPage', () => { @@ -37,7 +40,7 @@ describe('StorageManagementPage', () => { expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers'); }); - it('navigates to /admin/storage/tiers when the Tiers tab is clicked', async () => { + it('navigates to /admin/infrastructure/storage/tiers when the Tiers tab is clicked', async () => { const { user } = renderPage('backends'); await user.click(screen.getByRole('tab', { name: 'Tiers' })); diff --git a/libs/ui-components/src/pages/admin/StorageManagementPage.tsx b/libs/ui-components/src/pages/admin/StorageManagementPage.tsx index f2f079a5..e0cd8563 100644 --- a/libs/ui-components/src/pages/admin/StorageManagementPage.tsx +++ b/libs/ui-components/src/pages/admin/StorageManagementPage.tsx @@ -26,7 +26,7 @@ export const StorageManagementPage = ({ activeTab }: { activeTab: StorageTab }) activeKey={activeTab} onSelect={(_event, tabKey) => { if (isStorageTab(tabKey)) { - navigate(`/admin/storage/${tabKey}`, { replace: true }); + navigate(`/admin/infrastructure/storage/${tabKey}`, { replace: true }); } }} aria-label={t('Storage tabs')} diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index c88f00f0..916059c5 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -32,6 +32,7 @@ import { VirtualNetworks, } from '@osac/types'; import type { + InstanceType as PrivateInstanceType, Tenant as PrivateTenant, StorageBackend, StorageBackendsCreateRequest, @@ -49,6 +50,7 @@ import type { TenantsCreateResponse, } from '@osac/types/private'; import { + InstanceTypes as PrivateInstanceTypes, Tenants as PrivateTenants, StorageBackendState, StorageBackends, @@ -69,6 +71,7 @@ export type MockApiFixtures = { securityGroups?: SecurityGroup[]; identityProviders?: IdentityProvider[]; instanceTypes?: InstanceType[]; + privateInstanceTypes?: PrivateInstanceType[]; storageBackends?: StorageBackend[]; storageTiers?: StorageTier[]; }; @@ -167,6 +170,7 @@ export const createMockConnectTransport = ( const subnets = fixtures.subnets ?? []; const securityGroups = fixtures.securityGroups ?? []; const instanceTypes = fixtures.instanceTypes ?? []; + const privateInstanceTypes = fixtures.privateInstanceTypes ?? []; const storageBackends = fixtures.storageBackends ?? []; const storageTiers = fixtures.storageTiers ?? []; @@ -358,6 +362,17 @@ export const createMockConnectTransport = ( delete: () => ({}), }); + router.service(PrivateInstanceTypes, { + list: () => ({ + items: privateInstanceTypes, + size: privateInstanceTypes.length, + total: privateInstanceTypes.length, + }), + get: (req) => ({ + object: privateInstanceTypes.find((item) => item.id === req.id), + }), + }); + router.service(PrivateTenants, { list: () => ({ items: tenants,