From 95518765ee6b5382357ca3cb1f177a1015fcc10d Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Wed, 4 Oct 2023 15:31:30 -0400 Subject: [PATCH] feat: Kubernetes cluster plugin (#18760) * refactor: k8s plugins Signed-off-by: Matthew Clarke * feat: add kubernetes cluster plugin Signed-off-by: Matthew Clarke * fix: yarn fix Signed-off-by: Matthew Clarke * fix: tsc Signed-off-by: Matthew Clarke * fix: types Signed-off-by: Matthew Clarke * chore: missing changeset Signed-off-by: Matthew Clarke * chore: update api report Signed-off-by: Matthew Clarke --------- Signed-off-by: Matthew Clarke --- .changeset/slow-dodos-remember.md | 8 + packages/app/package.json | 1 + .../app/src/components/catalog/EntityPage.tsx | 11 ++ plugins/kubernetes-cluster/.eslintrc.js | 1 + plugins/kubernetes-cluster/api-report.md | 24 +++ plugins/kubernetes-cluster/catalog-info.yaml | 10 ++ plugins/kubernetes-cluster/package.json | 80 ++++++++++ plugins/kubernetes-cluster/src/Router.tsx | 56 +++++++ .../ApiResources/ApiResources.test.tsx | 64 ++++++++ .../components/ApiResources/ApiResources.tsx | 80 ++++++++++ .../ApiResources/useApiResources.ts | 48 ++++++ .../ClusterOverview/ClusterOverview.test.tsx | 60 ++++++++ .../ClusterOverview/ClusterOverview.tsx | 65 ++++++++ .../src/components/ClusterOverview/index.ts | 17 +++ .../components/ClusterOverview/useCluster.ts | 40 +++++ .../KubernetesClusterContent.tsx | 64 ++++++++ .../KubernetesClusterContent/index.ts | 17 +++ .../KubernetesClusterErrorContext.tsx | 50 ++++++ .../src/components/Nodes/Nodes.test.tsx | 107 +++++++++++++ .../src/components/Nodes/Nodes.tsx | 144 ++++++++++++++++++ .../src/components/Nodes/useNodes.ts | 48 ++++++ plugins/kubernetes-cluster/src/index.ts | 27 ++++ plugins/kubernetes-cluster/src/plugin.ts | 54 +++++++ plugins/kubernetes-react/api-report.md | 18 ++- .../src/api/KubernetesBackendClient.ts | 2 +- plugins/kubernetes-react/src/api/types.ts | 11 +- plugins/kubernetes/dev/index.tsx | 6 + yarn.lock | 52 ++++++- 28 files changed, 1158 insertions(+), 7 deletions(-) create mode 100644 .changeset/slow-dodos-remember.md create mode 100644 plugins/kubernetes-cluster/.eslintrc.js create mode 100644 plugins/kubernetes-cluster/api-report.md create mode 100644 plugins/kubernetes-cluster/catalog-info.yaml create mode 100644 plugins/kubernetes-cluster/package.json create mode 100644 plugins/kubernetes-cluster/src/Router.tsx create mode 100644 plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx create mode 100644 plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx create mode 100644 plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts create mode 100644 plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx create mode 100644 plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx create mode 100644 plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts create mode 100644 plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts create mode 100644 plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx create mode 100644 plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts create mode 100644 plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx create mode 100644 plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx create mode 100644 plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx create mode 100644 plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts create mode 100644 plugins/kubernetes-cluster/src/index.ts create mode 100644 plugins/kubernetes-cluster/src/plugin.ts diff --git a/.changeset/slow-dodos-remember.md b/.changeset/slow-dodos-remember.md new file mode 100644 index 0000000000..fcc49e3f45 --- /dev/null +++ b/.changeset/slow-dodos-remember.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-cluster': patch +'@backstage/plugin-kubernetes-react': patch +'@backstage/plugin-kubernetes': patch +--- + +Add Kubernetes cluster plugin. Viewing Kubernetes clusters as an Admin from Backstage diff --git a/packages/app/package.json b/packages/app/package.json index e23da6c8dc..6e06d2ca03 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -52,6 +52,7 @@ "@backstage/plugin-jenkins": "workspace:^", "@backstage/plugin-kafka": "workspace:^", "@backstage/plugin-kubernetes": "workspace:^", + "@backstage/plugin-kubernetes-cluster": "workspace:^", "@backstage/plugin-lighthouse": "workspace:^", "@backstage/plugin-linguist": "workspace:^", "@backstage/plugin-linguist-common": "workspace:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index ae78d0b771..81af3b91e7 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -103,6 +103,10 @@ import { } from '@backstage/plugin-jenkins'; import { EntityKafkaContent } from '@backstage/plugin-kafka'; import { EntityKubernetesContent } from '@backstage/plugin-kubernetes'; +import { + isKubernetesClusterAvailable, + EntityKubernetesClusterContent, +} from '@backstage/plugin-kubernetes-cluster'; import { EntityLastLighthouseAuditCard, EntityLighthouseContent, @@ -898,6 +902,13 @@ const resourcePage = ( + + + Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { Entity } from '@backstage/catalog-model'; +import { default as React_2 } from 'react'; + +// @public +export const EntityKubernetesClusterContent: ( + props: EntityKubernetesClusterContentProps, +) => JSX.Element; + +// @public +export type EntityKubernetesClusterContentProps = {}; + +// @public (undocumented) +export const isKubernetesClusterAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const Router: () => React_2.JSX.Element; +``` diff --git a/plugins/kubernetes-cluster/catalog-info.yaml b/plugins/kubernetes-cluster/catalog-info.yaml new file mode 100644 index 0000000000..3da5102e46 --- /dev/null +++ b/plugins/kubernetes-cluster/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-kubernetes-cluster + title: '@backstage/plugin-kubernetes-cluster' + description: A Backstage plugin that integrates towards Kubernetes clusters +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: kubernetes-maintainers diff --git a/plugins/kubernetes-cluster/package.json b/plugins/kubernetes-cluster/package.json new file mode 100644 index 0000000000..2e1c8d8309 --- /dev/null +++ b/plugins/kubernetes-cluster/package.json @@ -0,0 +1,80 @@ +{ + "name": "@backstage/plugin-kubernetes-cluster", + "description": "A Backstage plugin that shows details of Kubernetes clusters", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/kubernetes-cluster" + }, + "keywords": [ + "backstage", + "kubernetes" + ], + "sideEffects": false, + "scripts": { + "build": "backstage-cli package build", + "start": "backstage-cli package start", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean" + }, + "dependencies": { + "@backstage/catalog-model": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-kubernetes-common": "workspace:^", + "@backstage/plugin-kubernetes-react": "workspace:^", + "@backstage/theme": "workspace:^", + "@kubernetes-models/apimachinery": "^1.1.0", + "@kubernetes-models/base": "^4.0.1", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.61", + "@types/react": "^16.13.1 || ^17.0.0", + "cronstrue": "^2.2.0", + "js-yaml": "^4.0.0", + "kubernetes-models": "^4.1.0", + "lodash": "^4.17.21", + "luxon": "^3.0.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0", + "react-dom": "^16.13.1 || ^17.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/dom": "^8.0.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^8.0.0", + "@testing-library/user-event": "^14.0.0", + "@types/node": "^16.11.26", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/kubernetes-cluster/src/Router.tsx b/plugins/kubernetes-cluster/src/Router.tsx new file mode 100644 index 0000000000..86563439ce --- /dev/null +++ b/plugins/kubernetes-cluster/src/Router.tsx @@ -0,0 +1,56 @@ +/* + * 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. + */ + +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Route, Routes } from 'react-router-dom'; +import { MissingAnnotationEmptyState } from '@backstage/core-components'; +import { ANNOTATION_KUBERNETES_API_SERVER } from '@backstage/plugin-kubernetes-common'; +import { KubernetesClusterContent } from './components/KubernetesClusterContent'; + +/** + * + * + * @public + */ +export const isKubernetesClusterAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[ANNOTATION_KUBERNETES_API_SERVER]); + +/** + * + * + * @public + */ +export const Router = () => { + const { entity } = useEntity(); + + if (isKubernetesClusterAvailable(entity)) { + return ( + + } /> + + ); + } + + return ( + <> + + + ); +}; diff --git a/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx new file mode 100644 index 0000000000..601ba54a6d --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx @@ -0,0 +1,64 @@ +/* + * 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. + */ +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ApiResources } from './ApiResources'; +import '@testing-library/jest-dom'; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => { + return { + entity: { + metadata: { + name: 'some-cluster', + }, + }, + }; + }, +})); + +jest.mock('./useApiResources', () => ({ + useApiResources: jest.fn().mockReturnValue({ + loading: false, + value: { + groups: [ + { + name: 'some-apiVersion', + preferredVersion: { + groupVersion: 'some-group-version', + }, + }, + ], + }, + }), +})); + +describe('ApiResources', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('displays ApiResources', async () => { + const { getByText } = await renderInTestApp(); + + // Title + expect(getByText('Name')).toBeInTheDocument(); + expect(getByText('Preferred Version')).toBeInTheDocument(); + + // Row 1 + expect(getByText('some-apiVersion')).toBeInTheDocument(); + expect(getByText('some-group-version')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx new file mode 100644 index 0000000000..f6e8bc65ce --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2023 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 { useApiResources } from './useApiResources'; +import React, { useCallback, useEffect } from 'react'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Table, TableColumn } from '@backstage/core-components'; +import { IAPIGroup } from '@kubernetes-models/apimachinery/apis/meta/v1'; +import { useKubernetesClusterError } from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +const defaultColumns: TableColumn[] = [ + { + title: 'Name', + highlight: true, + render: (apiGroup: IAPIGroup) => { + return apiGroup.name; + }, + }, + { + title: 'Preferred Version', + highlight: true, + render: (apiGroup: IAPIGroup) => { + return apiGroup.preferredVersion?.groupVersion; + }, + }, +]; + +export const ApiResources = () => { + const classes = useStyles(); + const { entity } = useEntity(); + const { setError } = useKubernetesClusterError(); + const setErrorCallback = useCallback(setError, [setError]); + const { value, error, loading } = useApiResources({ + clusterName: entity.metadata.name, + }); + + useEffect(() => { + if (error) { + setErrorCallback(error.message); + } + }, [error, setErrorCallback]); + + return ( + + {error !== undefined + ? 'Error loading API Resources' + : 'No API Resources found'} + + } + columns={defaultColumns} + /> + ); +}; diff --git a/plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts b/plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts new file mode 100644 index 0000000000..b28b0b0b67 --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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 useAsync from 'react-use/lib/useAsync'; + +import { useApi } from '@backstage/core-plugin-api'; +import { kubernetesApiRef } from '@backstage/plugin-kubernetes-react'; +import { IAPIGroupList } from '@kubernetes-models/apimachinery/apis/meta/v1'; + +/** + * Arguments for useApiResources + * + * @public + */ +export interface ApiResourcesOptions { + clusterName: string; +} + +/** + * Retrieves the logs for the given pod + * + * @public + */ +export const useApiResources = ({ clusterName }: ApiResourcesOptions) => { + const kubernetesApi = useApi(kubernetesApiRef); + return useAsync(async () => { + return await kubernetesApi + .proxy({ + clusterName, + path: '/apis', + }) + .then(r => { + return r.json() as Promise; + }); + }, [clusterName]); +}; diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx new file mode 100644 index 0000000000..6398093a74 --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx @@ -0,0 +1,60 @@ +/* + * 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. + */ +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ClusterOverview } from './ClusterOverview'; +import '@testing-library/jest-dom'; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => { + return { + entity: { + metadata: { + name: 'some-cluster', + }, + }, + }; + }, +})); + +jest.mock('./useCluster', () => ({ + useCluster: jest.fn().mockReturnValue({ + loading: false, + value: { + name: 'some-cluster', + authProvider: 'google', + }, + }), +})); + +describe('ClusterOverview', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('displays ClusterOverview', async () => { + const { getByText, queryAllByText } = await renderInTestApp( + , + ); + + expect(getByText('Name')).toBeInTheDocument(); + expect(getByText('some-cluster')).toBeInTheDocument(); + expect(getByText('Backstage Auth Provider')).toBeInTheDocument(); + expect(getByText('google')).toBeInTheDocument(); + expect(getByText('OIDC Token Provider')).toBeInTheDocument(); + expect(getByText('Dashboard Link')).toBeInTheDocument(); + expect(queryAllByText('N/A')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx new file mode 100644 index 0000000000..a4d4ffc40c --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2023 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, { useCallback, useEffect } from 'react'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useCluster } from './useCluster'; +import { Skeleton } from '@material-ui/lab'; +import { Theme, createStyles, makeStyles } from '@material-ui/core'; +import { useKubernetesClusterError } from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext'; + +const useStyles = makeStyles((_theme: Theme) => + createStyles({ + root: { + height: '100%', + }, + }), +); + +export const ClusterOverview = () => { + const classes = useStyles(); + const { entity } = useEntity(); + const { value, loading, error } = useCluster({ + clusterName: entity.metadata.name, + }); + const { setError } = useKubernetesClusterError(); + const setErrorCallback = useCallback(setError, [setError]); + useEffect(() => { + if (error) { + setErrorCallback(error.message); + } + }, [error, setErrorCallback]); + + return ( + + {!value && loading && ( + <> + + + )} + {value && ( + + )} + + ); +}; diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts b/plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts new file mode 100644 index 0000000000..5a46125f3e --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 { ClusterOverview } from './ClusterOverview'; diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts b/plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts new file mode 100644 index 0000000000..05106cdc5b --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2023 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 useAsync from 'react-use/lib/useAsync'; + +import { useApi } from '@backstage/core-plugin-api'; +import { kubernetesApiRef } from '@backstage/plugin-kubernetes-react'; + +/** + * Arguments for useApiResources + * + * @public + */ +export interface UseClusterOptions { + clusterName: string; +} + +/** + * Retrieves the logs for the given pod + * + * @public + */ +export const useCluster = ({ clusterName }: UseClusterOptions) => { + const kubernetesApi = useApi(kubernetesApiRef); + return useAsync(async () => { + return await kubernetesApi.getCluster(clusterName); + }, [clusterName]); +}; diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx new file mode 100644 index 0000000000..665ca2acdd --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2023 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 { ApiResources } from '../ApiResources/ApiResources'; +import { Grid, Typography } from '@material-ui/core'; +import { Nodes } from '../Nodes/Nodes'; +import { ClusterOverview } from '../ClusterOverview'; +import { + KubernetesClusterErrorProvider, + useKubernetesClusterError, +} from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext'; +import { WarningPanel } from '@backstage/core-components'; + +const ContentGrid = () => { + const { error } = useKubernetesClusterError(); + return ( + <> + + {error && ( + + + {error} + + + )} + + + + + + + + + + + + ); +}; + +/** + * + * + * @public + */ +export const KubernetesClusterContent = () => { + return ( + + + + ); +}; diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts new file mode 100644 index 0000000000..2f5850c10c --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 { KubernetesClusterContent } from './KubernetesClusterContent'; diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx b/plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx new file mode 100644 index 0000000000..049a7b541d --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2023 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, { useCallback, useContext, useState } from 'react'; + +export interface ErrorContext { + error?: string; + setError: (message: string) => void; +} + +export const KubernetesClusterErrorContext = React.createContext({ + setError: (_: string) => {}, +}); + +export interface KubernetesClusterErrorProviderProps { + children: React.ReactNode; +} + +export const KubernetesClusterErrorProvider = ({ + children, +}: KubernetesClusterErrorProviderProps) => { + const [error, setError] = useState(undefined); + + const contextValue: ErrorContext = { + error, + setError: useCallback((message: string) => setError(message), []), + }; + + return ( + + {children} + + ); +}; + +export const useKubernetesClusterError = () => { + return useContext(KubernetesClusterErrorContext); +}; diff --git a/plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx new file mode 100644 index 0000000000..f48fdfbcef --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx @@ -0,0 +1,107 @@ +/* + * 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. + */ +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { Nodes } from './Nodes'; +import '@testing-library/jest-dom'; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => { + return { + entity: { + metadata: { + name: 'some-cluster', + }, + }, + }; + }, +})); + +jest.mock('./useNodes', () => ({ + useNodes: jest.fn().mockReturnValue({ + loading: false, + value: { + items: [ + { + metadata: { + name: 'some-node-name', + }, + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + }, + ], + nodeInfo: { + operatingSystem: 'linux', + architecture: 'ARM', + }, + }, + }, + { + metadata: { + name: 'some-node-bad-name', + }, + spec: { + unschedulable: true, + }, + status: { + conditions: [ + { + type: 'Ready', + status: 'False', + }, + ], + nodeInfo: { + operatingSystem: 'windows', + architecture: 'x86', + }, + }, + }, + ], + }, + }), +})); + +describe('Nodes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('displays nodes table - ready schedulable node', async () => { + const { getByText } = await renderInTestApp(); + + expect(getByText('Nodes')).toBeInTheDocument(); + + // Titles + expect(getByText('Name')).toBeInTheDocument(); + expect(getByText('Schedulable')).toBeInTheDocument(); + expect(getByText('Status')).toBeInTheDocument(); + expect(getByText('OS')).toBeInTheDocument(); + + // Row 1 + expect(getByText('some-node-name')).toBeInTheDocument(); + expect(getByText('✅')).toBeInTheDocument(); + expect(getByText('Ready')).toBeInTheDocument(); + expect(getByText('linux (ARM)')).toBeInTheDocument(); + + // Row 2 + expect(getByText('some-node-bad-name')).toBeInTheDocument(); + expect(getByText('❌')).toBeInTheDocument(); + expect(getByText('Not Ready')).toBeInTheDocument(); + expect(getByText('windows (x86)')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx new file mode 100644 index 0000000000..e7f6e957ae --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2023 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 { useNodes } from './useNodes'; +import React, { useCallback, useEffect } from 'react'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + StructuredMetadataTable, + Table, + TableColumn, +} from '@backstage/core-components'; +import { INode } from 'kubernetes-models/v1'; +import { Grid, Typography, makeStyles } from '@material-ui/core'; +import { useKubernetesClusterError } from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext'; +import { KubernetesDrawer } from '@backstage/plugin-kubernetes-react'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +const defaultColumns: TableColumn[] = [ + { + title: 'Name', + highlight: true, + width: 'auto', + render: (node: INode) => { + return ( + + + + Node Info + + + + Addresses + { + accum[next.type] = next.address; + return accum; + }, {} as any) ?? {} + } + /> + + + Taints + { + accum[`${next.effect}`] = `${next.key} (${next.value})`; + return accum; + }, {} as any) ?? {} + } + /> + + + + ); + }, + }, + { + title: 'Schedulable', + align: 'center', + width: 'auto', + render: (node: INode) => { + if (node.spec?.unschedulable) { + return '❌'; + } + return '✅'; + }, + }, + { + title: 'Status', + width: 'auto', + render: (node: INode) => { + // TODO add an icon + const readyCondition = node.status?.conditions?.find(c => { + return c.type === 'Ready'; + }); + if (!readyCondition) { + return 'Unknown'; + } + return readyCondition.status === 'True' ? 'Ready' : 'Not Ready'; + }, + }, + { + title: 'OS', + width: 'auto', + render: (node: INode) => { + return `${node.status?.nodeInfo?.operatingSystem ?? 'unknown'} (${ + node.status?.nodeInfo?.architecture ?? '?' + })`; + }, + }, +]; + +export const Nodes = () => { + const classes = useStyles(); + const { entity } = useEntity(); + const { value, error, loading } = useNodes({ + clusterName: entity.metadata.name, + }); + const { setError } = useKubernetesClusterError(); + const setErrorCallback = useCallback(setError, [setError]); + useEffect(() => { + if (error) { + setErrorCallback(error.message); + } + }, [error, setErrorCallback]); + + return ( +
+ {error !== undefined ? 'Error loading nodes' : 'No nodes found'} + + } + columns={defaultColumns} + /> + ); +}; diff --git a/plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts b/plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts new file mode 100644 index 0000000000..1feaff3bdb --- /dev/null +++ b/plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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 useAsync from 'react-use/lib/useAsync'; + +import { useApi } from '@backstage/core-plugin-api'; +import { kubernetesApiRef } from '@backstage/plugin-kubernetes-react'; +import { NodeList } from 'kubernetes-models/v1'; + +/** + * Arguments for useApiResources + * + * @public + */ +export interface useNodesOptions { + clusterName: string; +} + +/** + * Retrieves nodes for a cluster + * + * @public + */ +export const useNodes = ({ clusterName }: useNodesOptions) => { + const kubernetesApi = useApi(kubernetesApiRef); + return useAsync(async () => { + return await kubernetesApi + .proxy({ + clusterName, + path: '/api/v1/nodes?limit=500', + }) + .then(r => { + return r.json() as Promise; + }); + }, [clusterName]); +}; diff --git a/plugins/kubernetes-cluster/src/index.ts b/plugins/kubernetes-cluster/src/index.ts new file mode 100644 index 0000000000..6171b151e4 --- /dev/null +++ b/plugins/kubernetes-cluster/src/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2023 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. + */ + +/** + * A Backstage plugin that integrates towards Kubernetes + * + * @packageDocumentation + */ + +export { + EntityKubernetesClusterContent, + type EntityKubernetesClusterContentProps, +} from './plugin'; +export { Router, isKubernetesClusterAvailable } from './Router'; diff --git a/plugins/kubernetes-cluster/src/plugin.ts b/plugins/kubernetes-cluster/src/plugin.ts new file mode 100644 index 0000000000..5cd9b2287c --- /dev/null +++ b/plugins/kubernetes-cluster/src/plugin.ts @@ -0,0 +1,54 @@ +/* + * 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. + */ +import { + createPlugin, + createRouteRef, + createRoutableExtension, +} from '@backstage/core-plugin-api'; + +export const rootCatalogKubernetesClusterRouteRef = createRouteRef({ + id: 'kubernetes-cluster', +}); + +export const kubernetesClusterPlugin = createPlugin({ + id: 'kubernetes-cluster', + apis: [], + routes: { + entityContent: rootCatalogKubernetesClusterRouteRef, + }, +}); + +/** + * Props of EntityKubernetesContent + * + * @public + */ +export type EntityKubernetesClusterContentProps = {}; + +/** + * Props of EntityKubernetesContent + * + * @public + */ +export const EntityKubernetesClusterContent: ( + props: EntityKubernetesClusterContentProps, +) => JSX.Element = kubernetesClusterPlugin.provide( + createRoutableExtension({ + name: 'EntityKubernetesClusterContent', + component: () => import('./Router').then(m => m.Router), + mountPoint: rootCatalogKubernetesClusterRouteRef, + }), +); diff --git a/plugins/kubernetes-react/api-report.md b/plugins/kubernetes-react/api-report.md index e182524426..bb8e4923f6 100644 --- a/plugins/kubernetes-react/api-report.md +++ b/plugins/kubernetes-react/api-report.md @@ -251,12 +251,22 @@ export type JobsAccordionsProps = { // @public (undocumented) export interface KubernetesApi { + // (undocumented) + getCluster(clusterName: string): Promise< + | { + name: string; + authProvider: string; + oidcTokenProvider?: string; + dashboardUrl?: string; + } + | undefined + >; // (undocumented) getClusters(): Promise< { name: string; authProvider: string; - oidcTokenProvider?: string | undefined; + oidcTokenProvider?: string; }[] >; // (undocumented) @@ -338,6 +348,12 @@ export class KubernetesBackendClient implements KubernetesApi { kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }); // (undocumented) + getCluster(clusterName: string): Promise<{ + name: string; + authProvider: string; + oidcTokenProvider?: string; + }>; + // (undocumented) getClusters(): Promise< { name: string; diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts index d4e11c43da..a769eb088d 100644 --- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts @@ -75,7 +75,7 @@ export class KubernetesBackendClient implements KubernetesApi { return this.handleResponse(response); } - private async getCluster(clusterName: string): Promise<{ + public async getCluster(clusterName: string): Promise<{ name: string; authProvider: string; oidcTokenProvider?: string; diff --git a/plugins/kubernetes-react/src/api/types.ts b/plugins/kubernetes-react/src/api/types.ts index c0f97ffd41..28cf2dde55 100644 --- a/plugins/kubernetes-react/src/api/types.ts +++ b/plugins/kubernetes-react/src/api/types.ts @@ -42,9 +42,18 @@ export interface KubernetesApi { { name: string; authProvider: string; - oidcTokenProvider?: string | undefined; + oidcTokenProvider?: string; }[] >; + getCluster(clusterName: string): Promise< + | { + name: string; + authProvider: string; + oidcTokenProvider?: string; + dashboardUrl?: string; + } + | undefined + >; getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 520f8802a9..fbc666ae13 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -116,6 +116,12 @@ class MockKubernetesClient implements KubernetesApi { return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }]; } + async getCluster( + _clusterName: string, + ): Promise<{ name: string; authProvider: string }> { + return { name: 'mock-cluster', authProvider: 'serviceAccount' }; + } + async proxy(_options: { clusterName: String; path: String }): Promise { return { kind: 'Namespace', diff --git a/yarn.lock b/yarn.lock index 2c48e783e0..38af62fcd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7643,6 +7643,49 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-kubernetes-cluster@workspace:^, @backstage/plugin-kubernetes-cluster@workspace:plugins/kubernetes-cluster": + version: 0.0.0-use.local + resolution: "@backstage/plugin-kubernetes-cluster@workspace:plugins/kubernetes-cluster" + dependencies: + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-kubernetes-common": "workspace:^" + "@backstage/plugin-kubernetes-react": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@kubernetes-models/apimachinery": ^1.1.0 + "@kubernetes-models/base": ^4.0.1 + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": 4.0.0-alpha.61 + "@testing-library/dom": ^8.0.0 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.0 + "@testing-library/user-event": ^14.0.0 + "@types/node": ^16.11.26 + "@types/react": ^16.13.1 || ^17.0.0 + cronstrue: ^2.2.0 + js-yaml: ^4.0.0 + kubernetes-models: ^4.1.0 + lodash: ^4.17.21 + luxon: ^3.0.0 + msw: ^1.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-kubernetes-common@workspace:^, @backstage/plugin-kubernetes-common@workspace:plugins/kubernetes-common": version: 0.0.0-use.local resolution: "@backstage/plugin-kubernetes-common@workspace:plugins/kubernetes-common" @@ -17989,10 +18032,10 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^16.9.2": - version: 16.18.53 - resolution: "@types/node@npm:16.18.53" - checksum: 26c05cde59664360c22e0dda70776ca6f1b35f0b94e4f84d2c21e2afa2e69ac3a2c99bbb57b43405f81df1b2598f6d707ccc7c4c31865f90e45c4625d8400518 +"@types/node@npm:^16.11.26, @types/node@npm:^16.9.2": + version: 16.18.54 + resolution: "@types/node@npm:16.18.54" + checksum: 208e8fc64f605e9cd55ab5e620a0fd019d8fe5629e3e3c5de869a149b731ab0fac5720c516dccc0ecc834ac27df754723dfe6554551663f016ba5096ea8851df languageName: node linkType: hard @@ -25544,6 +25587,7 @@ __metadata: "@backstage/plugin-jenkins": "workspace:^" "@backstage/plugin-kafka": "workspace:^" "@backstage/plugin-kubernetes": "workspace:^" + "@backstage/plugin-kubernetes-cluster": "workspace:^" "@backstage/plugin-lighthouse": "workspace:^" "@backstage/plugin-linguist": "workspace:^" "@backstage/plugin-linguist-common": "workspace:^"