From eaa78edcbc304370836a3cc5dbca792a20ce869d Mon Sep 17 00:00:00 2001 From: mclarke47 Date: Tue, 19 Oct 2021 19:49:45 +0100 Subject: [PATCH 1/4] refactor and refresh k8s plugin at interval Signed-off-by: mclarke47 --- .../src/components/Cluster/Cluster.test.tsx | 60 ++++++++ .../Cluster.tsx} | 120 +--------------- .../{KubernetesContent => Cluster}/index.ts | 2 +- .../ErrorPanel.test.tsx | 0 .../ErrorPanel.tsx | 0 .../src/components/ErrorPanel/index.ts | 16 +++ .../KubernetesContent.test.tsx | 8 +- .../src/components/KubernetesContent.tsx | 130 ++++++++++++++++++ .../src/hooks/useKubernetesObjects.ts | 86 ++++++------ 9 files changed, 259 insertions(+), 163 deletions(-) create mode 100644 plugins/kubernetes/src/components/Cluster/Cluster.test.tsx rename plugins/kubernetes/src/components/{KubernetesContent/KubernetesContent.tsx => Cluster/Cluster.tsx} (52%) rename plugins/kubernetes/src/components/{KubernetesContent => Cluster}/index.ts (91%) rename plugins/kubernetes/src/components/{KubernetesContent => ErrorPanel}/ErrorPanel.test.tsx (100%) rename plugins/kubernetes/src/components/{KubernetesContent => ErrorPanel}/ErrorPanel.tsx (100%) create mode 100644 plugins/kubernetes/src/components/ErrorPanel/index.ts rename plugins/kubernetes/src/components/{KubernetesContent => }/KubernetesContent.test.tsx (95%) create mode 100644 plugins/kubernetes/src/components/KubernetesContent.tsx diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx new file mode 100644 index 0000000000..ddadd95272 --- /dev/null +++ b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Cluster } from './Cluster'; + +jest.mock('../../hooks'); +import * as oneDeployment from '../../__fixtures__/1-deployments.json'; + +describe('Cluster', () => { + it('render 1 cluster', async () => { + const { getByText } = render( + wrapInTestApp( + (), + } as any)} + />, + ), + ); + + expect(getByText('cluster-1')).toBeInTheDocument(); + expect(getByText('10 pods')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.tsx similarity index 52% rename from plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx rename to plugins/kubernetes/src/components/Cluster/Cluster.tsx index 260c77ed9e..d238396f53 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.tsx @@ -23,32 +23,20 @@ import { Grid, Typography, } from '@material-ui/core'; -import { Entity } from '@backstage/catalog-model'; import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; -import { ErrorPanel } from './ErrorPanel'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { DeploymentsAccordions } from '../DeploymentsAccordions'; -import { ErrorReporting } from '../ErrorReporting'; import { groupResponses } from '../../utils/response'; -import { DetectedError, detectErrors } from '../../error-detection'; import { IngressesAccordions } from '../IngressesAccordions'; import { ServicesAccordions } from '../ServicesAccordions'; import { CustomResources } from '../CustomResources'; -import EmptyStateImage from '../../assets/emptystate.svg'; import { ClusterContext, GroupedResponsesContext, PodNamesWithErrorsContext, - useKubernetesObjects, } from '../../hooks'; -import { - Content, - Page, - Progress, - StatusError, - StatusOK, -} from '@backstage/core-components'; +import { StatusError, StatusOK } from '@backstage/core-components'; type ClusterSummaryProps = { clusterName: string; @@ -117,7 +105,7 @@ type ClusterProps = { children?: React.ReactNode; }; -const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { +export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); return ( @@ -153,107 +141,3 @@ const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { ); }; - -type KubernetesContentProps = { entity: Entity; children?: React.ReactNode }; - -export const KubernetesContent = ({ entity }: KubernetesContentProps) => { - const { kubernetesObjects, error } = useKubernetesObjects(entity); - - const clustersWithErrors = - kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; - - const detectedErrors = - kubernetesObjects !== undefined - ? detectErrors(kubernetesObjects) - : new Map(); - - return ( - - - {kubernetesObjects === undefined && error === undefined && } - - {/* errors retrieved from the kubernetes clusters */} - {clustersWithErrors.length > 0 && ( - - - - - - )} - - {/* other errors */} - {error !== undefined && ( - - - - - - )} - - {kubernetesObjects && ( - - - - - - - - - Your Clusters - - - {kubernetesObjects?.items.length <= 0 && ( - - - - No resources on any known clusters for{' '} - {entity.metadata.name} - - - - EmptyState - - - )} - {kubernetesObjects?.items.length > 0 && - kubernetesObjects?.items.map((item, i) => { - const podsWithErrors = new Set( - detectedErrors - .get(item.cluster.name) - ?.filter(de => de.kind === 'Pod') - .map(de => de.names) - .flat() ?? [], - ); - - return ( - - - - ); - })} - - - )} - - - ); -}; diff --git a/plugins/kubernetes/src/components/KubernetesContent/index.ts b/plugins/kubernetes/src/components/Cluster/index.ts similarity index 91% rename from plugins/kubernetes/src/components/KubernetesContent/index.ts rename to plugins/kubernetes/src/components/Cluster/index.ts index 1a77bd40b2..dc9df63d42 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/index.ts +++ b/plugins/kubernetes/src/components/Cluster/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { KubernetesContent } from './KubernetesContent'; +export { Cluster } from './Cluster'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx similarity index 100% rename from plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx rename to plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx similarity index 100% rename from plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx rename to plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx diff --git a/plugins/kubernetes/src/components/ErrorPanel/index.ts b/plugins/kubernetes/src/components/ErrorPanel/index.ts new file mode 100644 index 0000000000..2d3778e2c2 --- /dev/null +++ b/plugins/kubernetes/src/components/ErrorPanel/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { ErrorPanel } from './ErrorPanel'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.test.tsx b/plugins/kubernetes/src/components/KubernetesContent.test.tsx similarity index 95% rename from plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.test.tsx rename to plugins/kubernetes/src/components/KubernetesContent.test.tsx index cd4b902f5f..db8cd06782 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.test.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent.test.tsx @@ -18,11 +18,11 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { KubernetesContent } from './KubernetesContent'; -import { useKubernetesObjects } from '../../hooks'; +import { useKubernetesObjects } from '../hooks'; -jest.mock('../../hooks'); -import * as oneDeployment from '../../__fixtures__/1-deployments.json'; -import * as twoDeployments from '../../__fixtures__/2-deployments.json'; +jest.mock('../hooks'); +import * as oneDeployment from '../__fixtures__/1-deployments.json'; +import * as twoDeployments from '../__fixtures__/2-deployments.json'; describe('KubernetesContent', () => { it('render empty response', async () => { diff --git a/plugins/kubernetes/src/components/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent.tsx new file mode 100644 index 0000000000..036bcdb608 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesContent.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Divider, Grid, Typography } from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; +import { ErrorPanel } from './ErrorPanel'; +import { ErrorReporting } from './ErrorReporting'; +import { DetectedError, detectErrors } from '../error-detection'; +import { Cluster } from './Cluster'; +import EmptyStateImage from '../assets/emptystate.svg'; +import { useKubernetesObjects } from '../hooks'; +import { Content, Page, Progress } from '@backstage/core-components'; + +type KubernetesContentProps = { entity: Entity; children?: React.ReactNode }; + +export const KubernetesContent = ({ entity }: KubernetesContentProps) => { + const { kubernetesObjects, error } = useKubernetesObjects(entity); + + const clustersWithErrors = + kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; + + const detectedErrors = + kubernetesObjects !== undefined + ? detectErrors(kubernetesObjects) + : new Map(); + + return ( + + + {kubernetesObjects === undefined && error === undefined && } + + {/* errors retrieved from the kubernetes clusters */} + {clustersWithErrors.length > 0 && ( + + + + + + )} + + {/* other errors */} + {error !== undefined && ( + + + + + + )} + + {kubernetesObjects && ( + + + + + + + + + Your Clusters + + + {kubernetesObjects?.items.length <= 0 && ( + + + + No resources on any known clusters for{' '} + {entity.metadata.name} + + + + EmptyState + + + )} + {kubernetesObjects?.items.length > 0 && + kubernetesObjects?.items.map((item, i) => { + const podsWithErrors = new Set( + detectedErrors + .get(item.cluster.name) + ?.filter(de => de.kind === 'Pod') + .map(de => de.names) + .flat() ?? [], + ); + + return ( + + + + ); + })} + + + )} + + + ); +}; diff --git a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts index 4f19816296..51df92e8d2 100644 --- a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts +++ b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts @@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../api/types'; import { kubernetesAuthProvidersApiRef } from '../kubernetes-auth-provider/types'; import { useEffect, useState } from 'react'; +import { useInterval } from 'react-use'; import { KubernetesRequestBody, ObjectsByEntityResponse, @@ -38,51 +39,56 @@ export const useKubernetesObjects = (entity: Entity): KubernetesObjects => { const [error, setError] = useState(undefined); + const getObjects = async () => { + let clusters = []; + + try { + clusters = await kubernetesApi.getClusters(); + } catch (e) { + setError(e.message); + return; + } + + const authProviders: string[] = [ + ...new Set(clusters.map(c => c.authProvider)), + ]; + // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider + let requestBody: KubernetesRequestBody = { + entity, + }; + for (const authProviderStr of authProviders) { + // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously + try { + requestBody = + await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( + authProviderStr, + requestBody, + ); + } catch (e) { + setError(e.message); + return; + } + } + + try { + setKubernetesObjects(await kubernetesApi.getObjectsByEntity(requestBody)); + } catch (e) { + setError(e.message); + return; + } + }; + useEffect(() => { - (async () => { - let clusters = []; - - try { - clusters = await kubernetesApi.getClusters(); - } catch (e) { - setError(e.message); - return; - } - - const authProviders: string[] = [ - ...new Set(clusters.map(c => c.authProvider)), - ]; - // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider - let requestBody: KubernetesRequestBody = { - entity, - }; - for (const authProviderStr of authProviders) { - // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously - try { - requestBody = - await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( - authProviderStr, - requestBody, - ); - } catch (e) { - setError(e.message); - return; - } - } - - try { - setKubernetesObjects( - await kubernetesApi.getObjectsByEntity(requestBody), - ); - } catch (e) { - setError(e.message); - return; - } - })(); + getObjects(); /* eslint-disable react-hooks/exhaustive-deps */ }, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]); /* eslint-enable react-hooks/exhaustive-deps */ + useInterval(() => { + getObjects(); + // TODO make configurable + }, 10000); + return { kubernetesObjects, error, From 14df942bae7c2087493ce7346fc15856020c84ad Mon Sep 17 00:00:00 2001 From: mclarke47 Date: Tue, 19 Oct 2021 19:51:18 +0100 Subject: [PATCH 2/4] add changeset Signed-off-by: mclarke47 --- .changeset/bright-plants-travel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-plants-travel.md diff --git a/.changeset/bright-plants-travel.md b/.changeset/bright-plants-travel.md new file mode 100644 index 0000000000..cbfd73e648 --- /dev/null +++ b/.changeset/bright-plants-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Refresh Kubernetes plugin on interval From 4c08c30178e24c0dac5092d2310c04cd402b85bf Mon Sep 17 00:00:00 2001 From: mclarke47 Date: Tue, 19 Oct 2021 19:59:16 +0100 Subject: [PATCH 3/4] add refresh test Signed-off-by: mclarke47 --- .../src/hooks/useKubernetesObjects.test.ts | 28 ++++++++++++++++--- .../src/hooks/useKubernetesObjects.ts | 8 ++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts b/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts index 1d46eb14ea..0daeb93d73 100644 --- a/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts +++ b/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts @@ -71,10 +71,10 @@ describe('useKubernetesObjects', () => { const mockGetObjectsByEntity = jest.fn(); const mockDecorateRequestBodyForAuth = jest.fn(); - const expectMocksCalledCorrectly = () => { - expect(mockGetClusters).toBeCalledTimes(1); + const expectMocksCalledCorrectly = (numOfCalls: number = 1) => { + expect(mockGetClusters).toBeCalledTimes(numOfCalls); expect(mockGetClusters).toHaveBeenLastCalledWith(); - expect(mockDecorateRequestBodyForAuth).toBeCalledTimes(2); + expect(mockDecorateRequestBodyForAuth).toBeCalledTimes(numOfCalls * 2); expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith('google', { entity, }); @@ -82,7 +82,7 @@ describe('useKubernetesObjects', () => { 'authprovider2', entityWithAuthToken, ); - expect(mockGetObjectsByEntity).toBeCalledTimes(1); + expect(mockGetObjectsByEntity).toBeCalledTimes(numOfCalls); expect(mockGetObjectsByEntity).toHaveBeenLastCalledWith( entityWithAuthToken, ); @@ -110,6 +110,26 @@ describe('useKubernetesObjects', () => { expectMocksCalledCorrectly(); }); + it('should update on an interval', async () => { + (useApi as any).mockReturnValue({ + getClusters: mockGetClusters.mockResolvedValue(getClustersResponse), + getObjectsByEntity: + mockGetObjectsByEntity.mockResolvedValue(mockResponse), + decorateRequestBodyForAuth: + mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken), + }); + const { result, waitForNextUpdate } = renderHook(() => + useKubernetesObjects(entity, 100), + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current.error).toBeUndefined(); + expect(result.current.kubernetesObjects).toStrictEqual(mockResponse); + + expectMocksCalledCorrectly(2); + }); it('should return error when getObjectsByEntity throws', async () => { (useApi as any).mockReturnValue({ getClusters: mockGetClusters.mockResolvedValue(getClustersResponse), diff --git a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts index 51df92e8d2..8a8ec260fa 100644 --- a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts +++ b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts @@ -30,7 +30,10 @@ export interface KubernetesObjects { error: string | undefined; } -export const useKubernetesObjects = (entity: Entity): KubernetesObjects => { +export const useKubernetesObjects = ( + entity: Entity, + intervalMs: number = 10000, +): KubernetesObjects => { const kubernetesApi = useApi(kubernetesApiRef); const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef); const [kubernetesObjects, setKubernetesObjects] = useState< @@ -86,8 +89,7 @@ export const useKubernetesObjects = (entity: Entity): KubernetesObjects => { useInterval(() => { getObjects(); - // TODO make configurable - }, 10000); + }, intervalMs); return { kubernetesObjects, From 9a9c630ad87e042b77b55616d0fedcceb61d60f8 Mon Sep 17 00:00:00 2001 From: mclarke47 Date: Thu, 21 Oct 2021 22:01:27 +0100 Subject: [PATCH 4/4] better changeset message Signed-off-by: mclarke47 --- .changeset/bright-plants-travel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-plants-travel.md b/.changeset/bright-plants-travel.md index cbfd73e648..ad8a77d17b 100644 --- a/.changeset/bright-plants-travel.md +++ b/.changeset/bright-plants-travel.md @@ -2,4 +2,4 @@ '@backstage/plugin-kubernetes': patch --- -Refresh Kubernetes plugin on interval +The Kubernetes plugin will now re-fetch the kubernetes objects every ten seconds (not current configurable), this allows users to track the progress of deployments without refreshing the browser.