feat: Kubernetes cluster plugin (#18760)

* refactor: k8s plugins

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* feat: add kubernetes cluster plugin

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: yarn fix

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: tsc

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: types

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: missing changeset

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: update api report

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2023-10-04 15:31:30 -04:00
committed by GitHub
parent b74f157839
commit 95518765ee
28 changed files with 1158 additions and 7 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+24
View File
@@ -0,0 +1,24 @@
## API Report File for "@backstage/plugin-kubernetes-cluster"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
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;
```
@@ -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
+80
View File
@@ -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"
]
}
+56
View File
@@ -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 (
<Routes>
<Route path="/" element={<KubernetesClusterContent />} />
</Routes>
);
}
return (
<>
<MissingAnnotationEmptyState
annotation={ANNOTATION_KUBERNETES_API_SERVER}
/>
</>
);
};
@@ -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(<ApiResources />);
// Title
expect(getByText('Name')).toBeInTheDocument();
expect(getByText('Preferred Version')).toBeInTheDocument();
// Row 1
expect(getByText('some-apiVersion')).toBeInTheDocument();
expect(getByText('some-group-version')).toBeInTheDocument();
});
});
@@ -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<IAPIGroup>[] = [
{
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 (
<Table
title="API Resources"
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
isLoading={!value && loading}
data={value?.groups ?? []}
emptyContent={
<div className={classes.empty}>
{error !== undefined
? 'Error loading API Resources'
: 'No API Resources found'}
</div>
}
columns={defaultColumns}
/>
);
};
@@ -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<IAPIGroupList>;
});
}, [clusterName]);
};
@@ -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(
<ClusterOverview />,
);
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);
});
});
@@ -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 (
<InfoCard title="Cluster Overview" className={classes.root}>
{!value && loading && (
<>
<Skeleton height="35rem" />
</>
)}
{value && (
<StructuredMetadataTable
metadata={{
name: value.name,
'Backstage auth provider': value.authProvider,
'OIDC Token Provider': value.oidcTokenProvider ?? 'N/A',
'Dashboard Link': value.dashboardUrl ?? 'N/A',
}}
/>
)}
</InfoCard>
);
};
@@ -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';
@@ -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]);
};
@@ -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 (
<>
<Grid container>
{error && (
<Grid item xs={12}>
<WarningPanel title="Error loading Kubernetes Cluster Plugin">
<Typography>{error}</Typography>
</WarningPanel>
</Grid>
)}
<Grid item xs={6}>
<ClusterOverview />
</Grid>
<Grid item xs={6}>
<ApiResources />
</Grid>
<Grid item xs={12}>
<Nodes />
</Grid>
</Grid>
</>
);
};
/**
*
*
* @public
*/
export const KubernetesClusterContent = () => {
return (
<KubernetesClusterErrorProvider>
<ContentGrid />
</KubernetesClusterErrorProvider>
);
};
@@ -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';
@@ -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<ErrorContext>({
setError: (_: string) => {},
});
export interface KubernetesClusterErrorProviderProps {
children: React.ReactNode;
}
export const KubernetesClusterErrorProvider = ({
children,
}: KubernetesClusterErrorProviderProps) => {
const [error, setError] = useState<string | undefined>(undefined);
const contextValue: ErrorContext = {
error,
setError: useCallback((message: string) => setError(message), []),
};
return (
<KubernetesClusterErrorContext.Provider value={contextValue}>
{children}
</KubernetesClusterErrorContext.Provider>
);
};
export const useKubernetesClusterError = () => {
return useContext(KubernetesClusterErrorContext);
};
@@ -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(<Nodes />);
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();
});
});
@@ -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<INode>[] = [
{
title: 'Name',
highlight: true,
width: 'auto',
render: (node: INode) => {
return (
<KubernetesDrawer
kubernetesObject={node}
label={node.metadata?.name ?? 'unknown-node'}
>
<Grid container>
<Grid item xs={12}>
<Typography variant="h5">Node Info</Typography>
<StructuredMetadataTable metadata={node.status?.nodeInfo ?? {}} />
</Grid>
<Grid item xs={12}>
<Typography variant="h5">Addresses</Typography>
<StructuredMetadataTable
metadata={
node.status?.addresses?.reduce((accum, next) => {
accum[next.type] = next.address;
return accum;
}, {} as any) ?? {}
}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="h5">Taints</Typography>
<StructuredMetadataTable
metadata={
node.spec?.taints?.reduce((accum, next) => {
accum[`${next.effect}`] = `${next.key} (${next.value})`;
return accum;
}, {} as any) ?? {}
}
/>
</Grid>
</Grid>
</KubernetesDrawer>
);
},
},
{
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 (
<Table
title="Nodes"
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
isLoading={!value && loading}
data={value?.items ?? []}
emptyContent={
<div className={classes.empty}>
{error !== undefined ? 'Error loading nodes' : 'No nodes found'}
</div>
}
columns={defaultColumns}
/>
);
};
@@ -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<NodeList>;
});
}, [clusterName]);
};
+27
View File
@@ -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';
+54
View File
@@ -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,
}),
);
+17 -1
View File
@@ -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;
@@ -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;
+10 -1
View File
@@ -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<ObjectsByEntityResponse>;
+6
View File
@@ -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<any> {
return {
kind: 'Namespace',