Skip to content

Commit e18cf07

Browse files
authored
implement csv export: managed clusters, clustersets, clusterpools, discovered clusters, cluster status (stolostron#3671)
Signed-off-by: Randy Bruno Piverger <rbrunopi@redhat.com>
1 parent eb621e7 commit e18cf07

File tree

10 files changed

+571
-72
lines changed

10 files changed

+571
-72
lines changed

frontend/src/routes/Infrastructure/Clusters/ClusterPools/ClusterPools.test.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,38 @@ describe('ClusterPools page', () => {
545545
})
546546
})
547547

548+
describe('Export from clusterpool table', () => {
549+
test('export button should produce a file for download', async () => {
550+
nockIgnoreRBAC()
551+
nockIgnoreApiPaths()
552+
render(
553+
<RecoilRoot>
554+
<MemoryRouter>
555+
<ClusterPoolsTable clusterPools={[mockClusterPool]} clusters={[mockCluster]} emptyState={''} />
556+
</MemoryRouter>
557+
</RecoilRoot>
558+
)
559+
window.URL.createObjectURL = jest.fn()
560+
window.URL.revokeObjectURL = jest.fn()
561+
const documentBody = document.body.appendChild
562+
const documentCreate = document.createElement('a').dispatchEvent
563+
564+
const anchorMocked = { href: '', click: jest.fn(), download: 'table-values', style: { display: '' } } as any
565+
const createElementSpyOn = jest.spyOn(document, 'createElement').mockReturnValueOnce(anchorMocked)
566+
document.body.appendChild = jest.fn()
567+
document.createElement('a').dispatchEvent = jest.fn()
568+
569+
await clickByLabel('export-search-result')
570+
await clickByText('Export as CSV')
571+
572+
expect(createElementSpyOn).toHaveBeenCalledWith('a')
573+
expect(anchorMocked.download).toContain('table-values')
574+
575+
document.body.appendChild = documentBody
576+
document.createElement('a').dispatchEvent = documentCreate
577+
})
578+
})
579+
548580
describe('Destroy ClusterPool with claimed clusters', () => {
549581
beforeEach(async () => {
550582
nockIgnoreRBAC()

frontend/src/routes/Infrastructure/Clusters/ClusterPools/ClusterPools.tsx

+116-12
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
IAcmTableButtonAction,
2626
Provider,
2727
StatusType,
28+
ProviderLongTextMap,
2829
} from '../../../../ui-components'
2930
import { Fragment, useContext, useEffect, useMemo, useState } from 'react'
3031
import { useNavigate } from 'react-router-dom-v5-compat'
@@ -46,12 +47,13 @@ import {
4647
ResourceErrorCode,
4748
isClusterPoolDeleting,
4849
} from '../../../../resources'
49-
import { ClusterStatuses } from '../ClusterSets/components/ClusterStatuses'
50+
import { ClusterStatuses, getClusterStatusCount } from '../ClusterSets/components/ClusterStatuses'
5051
import { StatusField } from '../ManagedClusters/components/StatusField'
5152
import { useAllClusters } from '../ManagedClusters/components/useAllClusters'
5253
import { ClusterClaimModal, ClusterClaimModalProps } from './components/ClusterClaimModal'
5354
import { ScaleClusterPoolModal, ScaleClusterPoolModalProps } from './components/ScaleClusterPoolModal'
5455
import { UpdateReleaseImageModal, UpdateReleaseImageModalProps } from './components/UpdateReleaseImageModal'
56+
import { getMappedClusterPoolClusterSetClusters } from '../ClusterSets/components/useClusters'
5557

5658
export default function ClusterPoolsPage() {
5759
const alertContext = useContext(AcmAlertContext)
@@ -152,11 +154,14 @@ export default function ClusterPoolsPage() {
152154
)
153155
}
154156

157+
function determineProvider(clusterPool: ClusterPool) {
158+
if (clusterPool.spec?.platform?.aws) return Provider.aws
159+
if (clusterPool.spec?.platform?.gcp) return Provider.gcp
160+
if (clusterPool.spec?.platform?.azure) return Provider.azure
161+
}
162+
155163
function ClusterPoolProvider(props: { clusterPool: ClusterPool }) {
156-
let provider: Provider | undefined
157-
if (props.clusterPool.spec?.platform?.aws) provider = Provider.aws
158-
if (props.clusterPool.spec?.platform?.gcp) provider = Provider.gcp
159-
if (props.clusterPool.spec?.platform?.azure) provider = Provider.azure
164+
const provider: Provider | undefined = determineProvider(props.clusterPool)
160165

161166
if (!provider) return <>-</>
162167

@@ -170,9 +175,32 @@ export function ClusterPoolsTable(props: {
170175
tableActionButtons?: IAcmTableButtonAction[]
171176
}) {
172177
const { clusters } = props
173-
const { clusterImageSetsState, clusterClaimsState } = useSharedAtoms()
178+
const {
179+
clusterImageSetsState,
180+
clusterClaimsState,
181+
certificateSigningRequestsState,
182+
clusterDeploymentsState,
183+
managedClusterAddonsState,
184+
clusterManagementAddonsState,
185+
managedClusterInfosState,
186+
managedClustersState,
187+
agentClusterInstallsState,
188+
clusterCuratorsState,
189+
hostedClustersState,
190+
nodePoolsState,
191+
} = useSharedAtoms()
174192
const clusterImageSets = useRecoilValue(clusterImageSetsState)
175193
const clusterClaims = useRecoilValue(clusterClaimsState)
194+
const managedClusters = useRecoilValue(managedClustersState)
195+
const clusterDeployments = useRecoilValue(clusterDeploymentsState)
196+
const managedClusterInfos = useRecoilValue(managedClusterInfosState)
197+
const certificateSigningRequests = useRecoilValue(certificateSigningRequestsState)
198+
const managedClusterAddons = useRecoilValue(managedClusterAddonsState)
199+
const clusterManagementAddons = useRecoilValue(clusterManagementAddonsState)
200+
const clusterCurators = useRecoilValue(clusterCuratorsState)
201+
const agentClusterInstalls = useRecoilValue(agentClusterInstallsState)
202+
const hostedClusters = useRecoilValue(hostedClustersState)
203+
const nodePools = useRecoilValue(nodePoolsState)
176204

177205
const { clusterPools } = props
178206
const { t } = useTranslation()
@@ -184,6 +212,30 @@ export function ClusterPoolsTable(props: {
184212
const [updateReleaseImageModalProps, setUpdateReleaseImageModalProps] = useState<
185213
UpdateReleaseImageModalProps | undefined
186214
>()
215+
const clusterPoolClusters: Record<string, Cluster[]> = {}
216+
217+
props.clusterPools &&
218+
props.clusterPools.forEach((clusterPool) => {
219+
if (clusterPool.metadata.name) {
220+
const clusters = getMappedClusterPoolClusterSetClusters(
221+
managedClusters,
222+
clusterDeployments,
223+
managedClusterInfos,
224+
certificateSigningRequests,
225+
managedClusterAddons,
226+
clusterManagementAddons,
227+
clusterClaims,
228+
clusterCurators,
229+
agentClusterInstalls,
230+
hostedClusters,
231+
nodePools,
232+
undefined,
233+
clusterPool,
234+
undefined
235+
)
236+
clusterPoolClusters[clusterPool.metadata.name] = clusters
237+
}
238+
})
187239

188240
const modalColumns = useMemo(
189241
() => [
@@ -216,6 +268,15 @@ export function ClusterPoolsTable(props: {
216268

217269
const deletingPools = clusterPools?.filter((clusterPool) => isClusterPoolDeleting(clusterPool))
218270

271+
const getDistributionVersion = (clusterPool: ClusterPool) => {
272+
const imageSetRef = clusterPool.spec!.imageSetRef.name
273+
const imageSet = clusterImageSets.find((cis) => cis.metadata.name === imageSetRef)
274+
const releaseImage = imageSet?.spec?.releaseImage
275+
const tagStartIndex = releaseImage?.indexOf(':') ?? 0
276+
const version = releaseImage?.slice(tagStartIndex + 1, releaseImage.indexOf('-', tagStartIndex))
277+
return version ? `OpenShift ${version}` : '-'
278+
}
279+
219280
return (
220281
<Fragment>
221282
<BulkActionModal<ClusterPool> {...modalProps} />
@@ -225,6 +286,8 @@ export function ClusterPoolsTable(props: {
225286
<AcmTable<ClusterPool>
226287
items={clusterPools}
227288
disabledItems={deletingPools}
289+
showExportButton
290+
exportFilePrefix="clusterpool"
228291
addSubRows={(clusterPool: ClusterPool) => {
229292
const clusterPoolClusters = clusters.filter(
230293
(cluster) =>
@@ -278,6 +341,9 @@ export function ClusterPoolsTable(props: {
278341
cell: (clusterPool: ClusterPool) => {
279342
return clusterPool.metadata.name
280343
},
344+
exportContent: (clusterPool: ClusterPool) => {
345+
return clusterPool.metadata.name
346+
},
281347
},
282348
{
283349
header: t('table.namespace'),
@@ -286,6 +352,9 @@ export function ClusterPoolsTable(props: {
286352
cell: (clusterPool: ClusterPool) => {
287353
return clusterPool.metadata.namespace
288354
},
355+
exportContent: (clusterPool: ClusterPool) => {
356+
return clusterPool.metadata.namespace
357+
},
289358
},
290359
{
291360
header: t('table.cluster.statuses'),
@@ -296,6 +365,28 @@ export function ClusterPoolsTable(props: {
296365
return <ClusterStatuses clusterPool={clusterPool} />
297366
}
298367
},
368+
exportContent: (clusterPool: ClusterPool) => {
369+
if (isClusterPoolDeleting(clusterPool)) {
370+
return t('destroying')
371+
} else {
372+
const status = getClusterStatusCount(clusterPoolClusters[clusterPool.metadata.name!])
373+
const clusterStatusAvailable =
374+
status &&
375+
Object.values(status).find((val) => {
376+
return typeof val === 'number' && val > 0
377+
})
378+
379+
if (clusterStatusAvailable) {
380+
return (
381+
`healthy: ${status?.healthy}, running: ${status?.running}, ` +
382+
`warning: ${status?.warning}, progress: ${status?.progress}, ` +
383+
`danger: ${status?.danger}, detached: ${status?.detached}, ` +
384+
`pending: ${status?.pending}, sleep: ${status?.sleep}, ` +
385+
`unknown: ${status?.unknown}`
386+
)
387+
}
388+
}
389+
},
299390
},
300391
{
301392
header: t('table.available'),
@@ -312,24 +403,37 @@ export function ClusterPoolsTable(props: {
312403
)
313404
}
314405
},
406+
exportContent: (clusterPool: ClusterPool) => {
407+
if (!isClusterPoolDeleting(clusterPool)) {
408+
const ready = clusterPool?.status?.ready === undefined ? 0 : clusterPool?.status?.ready
409+
return t('outOf', {
410+
firstNumber: ready,
411+
secondNumber: clusterPool.spec!.size,
412+
})
413+
}
414+
},
315415
},
316416
{
317417
header: t('table.provider'),
318418
cell: (clusterPool: ClusterPool) => {
319419
return <ClusterPoolProvider clusterPool={clusterPool} />
320420
},
421+
exportContent: (clusterPool: ClusterPool) => {
422+
const provider = determineProvider(clusterPool)
423+
if (provider) {
424+
return ProviderLongTextMap[provider]
425+
}
426+
},
321427
},
322428
{
323429
header: t('table.distribution'),
324430
sort: 'spec.imageSetRef.name',
325431
search: 'spec.imageSetRef.name',
326432
cell: (clusterPool: ClusterPool) => {
327-
const imageSetRef = clusterPool.spec!.imageSetRef.name
328-
const imageSet = clusterImageSets.find((cis) => cis.metadata.name === imageSetRef)
329-
const releaseImage = imageSet?.spec?.releaseImage
330-
const tagStartIndex = releaseImage?.indexOf(':') ?? 0
331-
const version = releaseImage?.slice(tagStartIndex + 1, releaseImage.indexOf('-', tagStartIndex))
332-
return version ? `OpenShift ${version}` : '-'
433+
return getDistributionVersion(clusterPool)
434+
},
435+
exportContent: (clusterPool: ClusterPool) => {
436+
return getDistributionVersion(clusterPool)
333437
},
334438
},
335439
{

frontend/src/routes/Infrastructure/Clusters/ClusterSets/ClusterSets.test.tsx

+33
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
waitForNotText,
2525
typeByTestId,
2626
ocpApi,
27+
clickByLabel,
2728
} from '../../../../lib/test-util'
2829
import {
2930
mockClusterDeployments,
@@ -104,3 +105,35 @@ describe('ClusterSets page without Submariner', () => {
104105
await waitForNotText('Submariner')
105106
})
106107
})
108+
109+
describe('ClusterSets page with csv export', () => {
110+
beforeEach(() => {
111+
nockIgnoreRBAC()
112+
nockIgnoreApiPaths()
113+
render(
114+
<PluginContext.Provider value={{ isSubmarinerAvailable: false, dataContext: PluginDataContext, ocpApi }}>
115+
<Component />
116+
</PluginContext.Provider>
117+
)
118+
})
119+
test('export button should produce a file for download', async () => {
120+
window.URL.createObjectURL = jest.fn()
121+
window.URL.revokeObjectURL = jest.fn()
122+
const documentBody = document.body.appendChild
123+
const documentCreate = document.createElement('a').dispatchEvent
124+
125+
const anchorMocked = { href: '', click: jest.fn(), download: 'table-values', style: { display: '' } } as any
126+
const createElementSpyOn = jest.spyOn(document, 'createElement').mockReturnValueOnce(anchorMocked)
127+
document.body.appendChild = jest.fn()
128+
document.createElement('a').dispatchEvent = jest.fn()
129+
130+
await clickByLabel('export-search-result')
131+
await clickByText('Export as CSV')
132+
133+
expect(createElementSpyOn).toHaveBeenCalledWith('a')
134+
expect(anchorMocked.download).toContain('table-values')
135+
136+
document.body.appendChild = documentBody
137+
document.createElement('a').dispatchEvent = documentCreate
138+
})
139+
})

0 commit comments

Comments
 (0)