From 89194405ee30b8dadf9e53a583979575a75c892d Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Wed, 8 Mar 2023 11:22:08 +1300 Subject: [PATCH] fix(kube): improve dashboard load speed [EE-4941] (#8572) * apply changes from EE * clear query cache when logging out * Text transitions in smoother --- app/docker/views/dashboard/dashboard.html | 2 +- app/kubernetes/__module.js | 2 - app/kubernetes/react/views/index.ts | 5 + app/kubernetes/views/dashboard/dashboard.html | 64 -------- app/kubernetes/views/dashboard/dashboard.js | 8 - .../views/dashboard/dashboardController.js | 96 ------------ app/portainer/react/components/index.ts | 12 +- app/portainer/tags/queries.ts | 9 ++ app/portainer/tags/types.ts | 1 + .../azure/DashboardView/DashboardView.tsx | 3 + .../DashboardItem/DashboardGrid.css | 3 - .../DashboardItem/DashboardGrid.tsx | 4 +- .../DashboardItem/DashboardItem.tsx | 58 ++++++- .../PageHeader/PageHeader.module.css | 4 - .../components/PageHeader/PageHeader.tsx | 3 +- app/react/components/PageHeader/UserMenu.tsx | 6 +- .../TagSelector/TagSelector.test.tsx | 2 + app/react/kubernetes/DashboardView/.keep | 0 .../DashboardView/DashboardView.tsx | 93 ++++++++++++ .../DashboardView/EnvironmentInfo.tsx | 50 +++++++ app/react/kubernetes/DashboardView/index.ts | 1 + app/react/kubernetes/applications/.keep | 0 app/react/kubernetes/applications/queries.ts | 21 +++ app/react/kubernetes/applications/service.ts | 141 ++++++++++++++++++ app/react/kubernetes/configs/queries.ts | 20 ++- app/react/kubernetes/configs/service.ts | 20 ++- app/react/kubernetes/namespaces/.keep | 0 app/react/kubernetes/namespaces/queries.ts | 31 ++-- app/react/kubernetes/namespaces/service.ts | 35 ++++- app/react/kubernetes/namespaces/types.ts | 11 ++ app/react/kubernetes/volumes/queries.ts | 21 +++ app/react/kubernetes/volumes/service.ts | 33 ++++ app/react/portainer/environments/types.ts | 8 + app/setup-tests/server-handlers.ts | 6 +- package.json | 1 + yarn.lock | 5 + 36 files changed, 569 insertions(+), 210 deletions(-) delete mode 100644 app/kubernetes/views/dashboard/dashboard.html delete mode 100644 app/kubernetes/views/dashboard/dashboard.js delete mode 100644 app/kubernetes/views/dashboard/dashboardController.js delete mode 100644 app/react/components/DashboardItem/DashboardGrid.css delete mode 100644 app/react/components/PageHeader/PageHeader.module.css delete mode 100644 app/react/kubernetes/DashboardView/.keep create mode 100644 app/react/kubernetes/DashboardView/DashboardView.tsx create mode 100644 app/react/kubernetes/DashboardView/EnvironmentInfo.tsx create mode 100644 app/react/kubernetes/DashboardView/index.ts delete mode 100644 app/react/kubernetes/applications/.keep create mode 100644 app/react/kubernetes/applications/queries.ts create mode 100644 app/react/kubernetes/applications/service.ts delete mode 100644 app/react/kubernetes/namespaces/.keep create mode 100644 app/react/kubernetes/volumes/queries.ts create mode 100644 app/react/kubernetes/volumes/service.ts diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 063eef817..655828adb 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -78,7 +78,7 @@ -
+
diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 5ebe986fb..ab05117ef 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -46,8 +46,6 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) { throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); } - - await KubernetesNamespaceService.get(); } catch (e) { let params = {}; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index ac2509366..3d5a8b8d8 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable'; import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView'; +import { DashboardView } from '@/react/kubernetes/DashboardView'; import { ServicesView } from '@/react/kubernetes/ServicesView'; export const viewsModule = angular @@ -24,4 +25,8 @@ export const viewsModule = angular .component( 'kubernetesIngressesCreateView', r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), []) + ) + .component( + 'kubernetesDashboardView', + r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) ).name; diff --git a/app/kubernetes/views/dashboard/dashboard.html b/app/kubernetes/views/dashboard/dashboard.html deleted file mode 100644 index a076c70a3..000000000 --- a/app/kubernetes/views/dashboard/dashboard.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - -
-
-
- -
-
-
- -
- Environment info -
-
- - - - - - - - - - - - - - - - -
Environment - {{ ctrl.endpoint.Name }} -
URL{{ ctrl.endpoint.URL | stripprotocol }}
Tags{{ ctrl.endpointTags }}
-
-
-
-
- -
-
- - - -
- -
- - - -
-
- - - -
-
- - - -
-
-
diff --git a/app/kubernetes/views/dashboard/dashboard.js b/app/kubernetes/views/dashboard/dashboard.js deleted file mode 100644 index c2ada72c2..000000000 --- a/app/kubernetes/views/dashboard/dashboard.js +++ /dev/null @@ -1,8 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesDashboardView', { - templateUrl: './dashboard.html', - controller: 'KubernetesDashboardController', - controllerAs: 'ctrl', - bindings: { - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/dashboard/dashboardController.js b/app/kubernetes/views/dashboard/dashboardController.js deleted file mode 100644 index f080f949c..000000000 --- a/app/kubernetes/views/dashboard/dashboardController.js +++ /dev/null @@ -1,96 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; -import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; -import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; - -class KubernetesDashboardController { - /* @ngInject */ - constructor( - $async, - Notifications, - EndpointService, - KubernetesResourcePoolService, - KubernetesApplicationService, - KubernetesConfigurationService, - KubernetesVolumeService, - Authentication, - TagService - ) { - this.$async = $async; - this.Notifications = Notifications; - this.EndpointService = EndpointService; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesApplicationService = KubernetesApplicationService; - this.KubernetesConfigurationService = KubernetesConfigurationService; - this.KubernetesVolumeService = KubernetesVolumeService; - this.Authentication = Authentication; - this.TagService = TagService; - - this.onInit = this.onInit.bind(this); - this.getAll = this.getAll.bind(this); - this.getAllAsync = this.getAllAsync.bind(this); - } - - async getAllAsync() { - const isAdmin = this.Authentication.isAdmin(); - const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; - this.showEnvUrl = this.endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment && this.endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment; - - try { - const [pools, applications, configurations, volumes, tags] = await Promise.all([ - this.KubernetesResourcePoolService.get(), - this.KubernetesApplicationService.get(), - this.KubernetesConfigurationService.get(), - this.KubernetesVolumeService.get(undefined, storageClasses), - this.TagService.tags(), - ]); - this.applications = applications; - this.volumes = volumes; - - this.endpointTags = this.endpoint.TagIds.length - ? _.join( - _.filter( - _.map(this.endpoint.TagIds, (id) => { - const tag = tags.find((tag) => tag.Id === id); - return tag ? tag.Name : ''; - }), - Boolean - ), - ', ' - ) - : '-'; - - if (!isAdmin) { - this.pools = _.filter(pools, (pool) => !KubernetesNamespaceHelper.isSystemNamespace(pool.Namespace.Name)); - this.configurations = _.filter(configurations, (config) => !KubernetesConfigurationHelper.isSystemToken(config)); - } else { - this.pools = pools; - this.configurations = configurations; - } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to load dashboard data'); - } - } - - getAll() { - return this.$async(this.getAllAsync); - } - - async onInit() { - this.state = { - viewReady: false, - }; - - await this.getAll(); - - this.state.viewReady = true; - } - - $onInit() { - return this.$async(this.onInit); - } -} - -export default KubernetesDashboardController; -angular.module('portainer.kubernetes').controller('KubernetesDashboardController', KubernetesDashboardController); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 17f6d4916..45124cf47 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -121,7 +121,17 @@ export const componentsModule = angular .component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, [])) .component( 'dashboardItem', - r2a(DashboardItem, ['icon', 'type', 'value', 'children']) + r2a(DashboardItem, [ + 'icon', + 'type', + 'value', + 'to', + 'children', + 'pluralType', + 'isLoading', + 'isRefetching', + 'dataCy', + ]) ) .component( 'datatableSearchbar', diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts index dd730a3f3..b7ddff481 100644 --- a/app/portainer/tags/queries.ts +++ b/app/portainer/tags/queries.ts @@ -5,6 +5,7 @@ import { withError, withInvalidate, } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; import { createTag, getTags } from './tags.service'; import { Tag, TagId } from './types'; @@ -24,6 +25,14 @@ export function useTags({ }); } +export function useTagsForEnvironment(environmentId: EnvironmentId) { + const { data: tags, isLoading } = useTags({ + select: (tags) => tags.filter((tag) => tag.Endpoints[environmentId]), + }); + + return { tags, isLoading }; +} + export function useCreateTagMutation() { const queryClient = useQueryClient(); diff --git a/app/portainer/tags/types.ts b/app/portainer/tags/types.ts index f7c0de9a0..3440871e4 100644 --- a/app/portainer/tags/types.ts +++ b/app/portainer/tags/types.ts @@ -3,4 +3,5 @@ export type TagId = number; export interface Tag { ID: TagId; Name: string; + Endpoints: Record; } diff --git a/app/react/azure/DashboardView/DashboardView.tsx b/app/react/azure/DashboardView/DashboardView.tsx index 2cc1a11f7..6815e9ae3 100644 --- a/app/react/azure/DashboardView/DashboardView.tsx +++ b/app/react/azure/DashboardView/DashboardView.tsx @@ -34,12 +34,15 @@ export function DashboardView() { {!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && ( diff --git a/app/react/components/DashboardItem/DashboardGrid.css b/app/react/components/DashboardItem/DashboardGrid.css deleted file mode 100644 index a2f844a59..000000000 --- a/app/react/components/DashboardItem/DashboardGrid.css +++ /dev/null @@ -1,3 +0,0 @@ -.dashboard-grid { - @apply grid grid-cols-2 gap-3; -} diff --git a/app/react/components/DashboardItem/DashboardGrid.tsx b/app/react/components/DashboardItem/DashboardGrid.tsx index 349d5a217..bf2b95472 100644 --- a/app/react/components/DashboardItem/DashboardGrid.tsx +++ b/app/react/components/DashboardItem/DashboardGrid.tsx @@ -1,7 +1,5 @@ import { PropsWithChildren } from 'react'; -import './DashboardGrid.css'; - export function DashboardGrid({ children }: PropsWithChildren) { - return
{children}
; + return
{children}
; } diff --git a/app/react/components/DashboardItem/DashboardItem.tsx b/app/react/components/DashboardItem/DashboardItem.tsx index 5a2492bbe..bf2107b3c 100644 --- a/app/react/components/DashboardItem/DashboardItem.tsx +++ b/app/react/components/DashboardItem/DashboardItem.tsx @@ -1,25 +1,62 @@ import { ReactNode } from 'react'; import clsx from 'clsx'; +import { Loader2 } from 'lucide-react'; import { Icon, IconProps } from '@/react/components/Icon'; import { pluralize } from '@/portainer/helpers/strings'; +import { Link } from '@@/Link'; + interface Props extends IconProps { - value?: number; type: string; + pluralType?: string; // in case the pluralise function isn't suitable + isLoading?: boolean; + isRefetching?: boolean; + value?: number; + to?: string; children?: ReactNode; + dataCy?: string; } -export function DashboardItem({ value, icon, type, children }: Props) { - return ( +export function DashboardItem({ + icon, + type, + pluralType, + isLoading, + isRefetching, + value, + to, + children, + dataCy, +}: Props) { + const Item = (
+
+ Refreshing total + +
+
+ Loading total + +
- {typeof value !== 'undefined' ? value : '-'} + {typeof value === 'undefined' ? '-' : value}
- {pluralize(value || 0, type)} + {pluralize(value || 0, type, pluralType)}
@@ -61,4 +98,13 @@ export function DashboardItem({ value, icon, type, children }: Props) {
); + + if (to) { + return ( + + {Item} + + ); + } + return Item; } diff --git a/app/react/components/PageHeader/PageHeader.module.css b/app/react/components/PageHeader/PageHeader.module.css deleted file mode 100644 index bc4eb823d..000000000 --- a/app/react/components/PageHeader/PageHeader.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.reloadButton { - padding: 0; - margin: 0; -} diff --git a/app/react/components/PageHeader/PageHeader.tsx b/app/react/components/PageHeader/PageHeader.tsx index bd38f7945..c2f854ad3 100644 --- a/app/react/components/PageHeader/PageHeader.tsx +++ b/app/react/components/PageHeader/PageHeader.tsx @@ -7,7 +7,6 @@ import { Breadcrumbs } from './Breadcrumbs'; import { Crumb } from './Breadcrumbs/Breadcrumbs'; import { HeaderContainer } from './HeaderContainer'; import { HeaderTitle } from './HeaderTitle'; -import styles from './PageHeader.module.css'; interface Props { id?: string; @@ -42,7 +41,7 @@ export function PageHeader({ color="none" size="large" onClick={onClickedRefresh} - className={styles.reloadButton} + className="m-0 p-0 focus:text-inherit" disabled={loading} > diff --git a/app/react/components/PageHeader/UserMenu.tsx b/app/react/components/PageHeader/UserMenu.tsx index b6e1de2a5..0e7fe48ef 100644 --- a/app/react/components/PageHeader/UserMenu.tsx +++ b/app/react/components/PageHeader/UserMenu.tsx @@ -8,6 +8,7 @@ import { UISrefProps, useSref } from '@uirouter/react'; import clsx from 'clsx'; import { User, ChevronDown } from 'lucide-react'; +import { queryClient } from '@/react-tools/react-query'; import { AutomationTestingProps } from '@/types'; import { useUser } from '@/react/hooks/useUser'; @@ -78,7 +79,10 @@ function MenuLink({ return ( { + queryClient.clear(); + anchorProps.onClick(e); + }} className={styles.menuLink} aria-label={label} data-cy={dataCy} diff --git a/app/react/components/TagSelector/TagSelector.test.tsx b/app/react/components/TagSelector/TagSelector.test.tsx index a547ae973..c5eb3d157 100644 --- a/app/react/components/TagSelector/TagSelector.test.tsx +++ b/app/react/components/TagSelector/TagSelector.test.tsx @@ -19,10 +19,12 @@ test('should show the selected tags', async () => { { ID: 1, Name: 'tag1', + Endpoints: {}, }, { ID: 2, Name: 'tag2', + Endpoints: {}, }, ]; diff --git a/app/react/kubernetes/DashboardView/.keep b/app/react/kubernetes/DashboardView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/DashboardView/DashboardView.tsx b/app/react/kubernetes/DashboardView/DashboardView.tsx new file mode 100644 index 000000000..81820efb5 --- /dev/null +++ b/app/react/kubernetes/DashboardView/DashboardView.tsx @@ -0,0 +1,93 @@ +import { Box, Database, Layers, Lock } from 'lucide-react'; +import { useQueryClient } from 'react-query'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; +import { DashboardItem } from '@@/DashboardItem/DashboardItem'; +import { PageHeader } from '@@/PageHeader'; + +import { useNamespaces } from '../namespaces/queries'; +import { useApplicationsForCluster } from '../applications/queries'; +import { useConfigurationsForCluster } from '../configs/queries'; +import { usePVCsForCluster } from '../volumes/queries'; + +import { EnvironmentInfo } from './EnvironmentInfo'; + +export function DashboardView() { + const queryClient = useQueryClient(); + const environmentId = useEnvironmentId(); + const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); + const namespaceNames = namespaces && Object.keys(namespaces); + const { data: applications, ...applicationsQuery } = + useApplicationsForCluster(environmentId, namespaceNames); + const { data: configurations, ...configurationsQuery } = + useConfigurationsForCluster(environmentId, namespaceNames); + const { data: pvcs, ...pvcsQuery } = usePVCsForCluster( + environmentId, + namespaceNames + ); + + return ( + <> + + queryClient.invalidateQueries(['environments', environmentId]) + } + /> +
+ + + + + + + +
+ + ); +} diff --git a/app/react/kubernetes/DashboardView/EnvironmentInfo.tsx b/app/react/kubernetes/DashboardView/EnvironmentInfo.tsx new file mode 100644 index 000000000..a3c45d680 --- /dev/null +++ b/app/react/kubernetes/DashboardView/EnvironmentInfo.tsx @@ -0,0 +1,50 @@ +import { Gauge } from 'lucide-react'; + +import { stripProtocol } from '@/portainer/filters/filters'; +import { useTagsForEnvironment } from '@/portainer/tags/queries'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useEnvironment } from '@/react/portainer/environments/queries'; + +import { Widget, WidgetTitle, WidgetBody } from '@@/Widget'; + +export function EnvironmentInfo() { + const environmentId = useEnvironmentId(); + const { data: environmentData, ...environmentQuery } = + useEnvironment(environmentId); + const tagsQuery = useTagsForEnvironment(environmentId); + const tagNames = tagsQuery.tags?.map((tag) => tag.Name).join(', ') || '-'; + + return ( + + + + {environmentQuery.isError &&
Failed to load environment
} + {environmentData && ( + + + + + + + + + + + + + + + +
Environment + {environmentData.Name} +
URL + {stripProtocol(environmentData.URL) || '-'} +
Tags{tagNames}
+ )} +
+
+ ); +} diff --git a/app/react/kubernetes/DashboardView/index.ts b/app/react/kubernetes/DashboardView/index.ts new file mode 100644 index 000000000..ea829dbf3 --- /dev/null +++ b/app/react/kubernetes/DashboardView/index.ts @@ -0,0 +1 @@ +export { DashboardView } from './DashboardView'; diff --git a/app/react/kubernetes/applications/.keep b/app/react/kubernetes/applications/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/applications/queries.ts b/app/react/kubernetes/applications/queries.ts new file mode 100644 index 000000000..8f6aab7f2 --- /dev/null +++ b/app/react/kubernetes/applications/queries.ts @@ -0,0 +1,21 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withError } from '@/react-tools/react-query'; + +import { getApplicationsListForCluster } from './service'; + +// useQuery to get a list of all applications from an array of namespaces +export function useApplicationsForCluster( + environemtId: EnvironmentId, + namespaces?: string[] +) { + return useQuery( + ['environments', environemtId, 'kubernetes', 'applications'], + () => namespaces && getApplicationsListForCluster(environemtId, namespaces), + { + ...withError('Unable to retrieve applications'), + enabled: !!namespaces, + } + ); +} diff --git a/app/react/kubernetes/applications/service.ts b/app/react/kubernetes/applications/service.ts new file mode 100644 index 000000000..c95418396 --- /dev/null +++ b/app/react/kubernetes/applications/service.ts @@ -0,0 +1,141 @@ +import { Pod, PodList } from 'kubernetes-types/core/v1'; +import { + Deployment, + DeploymentList, + DaemonSet, + DaemonSetList, + StatefulSet, + StatefulSetList, +} from 'kubernetes-types/apps/v1'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function getApplicationsListForCluster( + environmentId: EnvironmentId, + namespaces: string[] +) { + try { + const applications = await Promise.all( + namespaces.map((namespace) => + getApplicationsListForNamespace(environmentId, namespace) + ) + ); + return applications.flat(); + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve applications for cluster' + ); + } +} + +// get a list of all Deployments, DaemonSets and StatefulSets in one namespace +export async function getApplicationsListForNamespace( + environmentId: EnvironmentId, + namespace: string +) { + try { + const [deployments, daemonSets, statefulSets, pods] = await Promise.all([ + getDeployments(environmentId, namespace), + getDaemonSets(environmentId, namespace), + getStatefulSets(environmentId, namespace), + getPods(environmentId, namespace), + ]); + // find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset) + const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets); + return [...deployments, ...daemonSets, ...statefulSets, ...nakedPods]; + } catch (e) { + throw parseAxiosError( + e as Error, + `Unable to retrieve applications in namespace ${namespace}` + ); + } +} + +async function getDeployments(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'deployments') + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve deployments'); + } +} + +async function getDaemonSets(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'daemonsets') + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve daemonsets'); + } +} + +async function getStatefulSets( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'statefulsets') + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve statefulsets'); + } +} + +async function getPods(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods` + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve pods'); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + appResource: 'deployments' | 'daemonsets' | 'statefulsets' +) { + return `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appResource}`; +} + +function getNakedPods( + pods: Pod[], + deployments: Deployment[], + daemonSets: DaemonSet[], + statefulSets: StatefulSet[] +) { + // naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset + // https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs + const appLabels = [ + ...deployments.map((deployment) => deployment.spec?.selector.matchLabels), + ...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels), + ...statefulSets.map( + (statefulSet) => statefulSet.spec?.selector.matchLabels + ), + ]; + + const nakedPods = pods.filter((pod) => { + const podLabels = pod.metadata?.labels; + // if the pod has no labels, it is naked + if (!podLabels) return true; + // if the pod has labels, but no app labels, it is naked + return !appLabels.some((appLabel) => { + if (!appLabel) return false; + return Object.entries(appLabel).every( + ([key, value]) => podLabels[key] === value + ); + }); + }); + + return nakedPods; +} diff --git a/app/react/kubernetes/configs/queries.ts b/app/react/kubernetes/configs/queries.ts index ad6cb4ef2..2b27653ff 100644 --- a/app/react/kubernetes/configs/queries.ts +++ b/app/react/kubernetes/configs/queries.ts @@ -2,9 +2,11 @@ import { useQuery } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { error as notifyError } from '@/portainer/services/notifications'; +import { withError } from '@/react-tools/react-query'; -import { getConfigMaps } from './service'; +import { getConfigurations, getConfigMapsForCluster } from './service'; +// returns a usequery hook for the formatted list of configmaps and secrets export function useConfigurations( environmentId: EnvironmentId, namespace?: string @@ -18,7 +20,7 @@ export function useConfigurations( namespace, 'configurations', ], - () => (namespace ? getConfigMaps(environmentId, namespace) : []), + () => (namespace ? getConfigurations(environmentId, namespace) : []), { onError: (err) => { notifyError('Failure', err as Error, 'Unable to get configurations'); @@ -27,3 +29,17 @@ export function useConfigurations( } ); } + +export function useConfigurationsForCluster( + environemtId: EnvironmentId, + namespaces?: string[] +) { + return useQuery( + ['environments', environemtId, 'kubernetes', 'configmaps'], + () => namespaces && getConfigMapsForCluster(environemtId, namespaces), + { + ...withError('Unable to retrieve applications'), + enabled: !!namespaces, + } + ); +} diff --git a/app/react/kubernetes/configs/service.ts b/app/react/kubernetes/configs/service.ts index cd3a4c6b6..6e48ec6db 100644 --- a/app/react/kubernetes/configs/service.ts +++ b/app/react/kubernetes/configs/service.ts @@ -3,7 +3,8 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { Configuration } from './types'; -export async function getConfigMaps( +// returns the formatted list of configmaps and secrets +export async function getConfigurations( environmentId: EnvironmentId, namespace: string ) { @@ -16,3 +17,20 @@ export async function getConfigMaps( throw parseAxiosError(e as Error, 'Unable to retrieve configmaps'); } } + +export async function getConfigMapsForCluster( + environmentId: EnvironmentId, + namespaces: string[] +) { + try { + const configmaps = await Promise.all( + namespaces.map((namespace) => getConfigurations(environmentId, namespace)) + ); + return configmaps.flat(); + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve ConfigMaps for cluster' + ); + } +} diff --git a/app/react/kubernetes/namespaces/.keep b/app/react/kubernetes/namespaces/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/namespaces/queries.ts b/app/react/kubernetes/namespaces/queries.ts index 057134698..397c878f5 100644 --- a/app/react/kubernetes/namespaces/queries.ts +++ b/app/react/kubernetes/namespaces/queries.ts @@ -3,9 +3,11 @@ import { useQuery } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { error as notifyError } from '@/portainer/services/notifications'; -import { getIngresses } from '../ingresses/service'; - -import { getNamespaces, getNamespace } from './service'; +import { + getNamespaces, + getNamespace, + getSelfSubjectAccessReview, +} from './service'; import { Namespaces } from './types'; export function useNamespaces(environmentId: EnvironmentId) { @@ -13,18 +15,23 @@ export function useNamespaces(environmentId: EnvironmentId) { ['environments', environmentId, 'kubernetes', 'namespaces'], async () => { const namespaces = await getNamespaces(environmentId); - const settledNamespacesPromise = await Promise.allSettled( - Object.keys(namespaces).map((namespace) => - getIngresses(environmentId, namespace).then(() => namespace) + const namespaceNames = Object.keys(namespaces); + // use seflsubjectaccess reviews to avoid forbidden requests + const allNamespaceAccessReviews = await Promise.all( + namespaceNames.map((namespaceName) => + getSelfSubjectAccessReview(environmentId, namespaceName) ) ); - const ns: Namespaces = {}; - settledNamespacesPromise.forEach((namespace) => { - if (namespace.status === 'fulfilled') { - ns[namespace.value] = namespaces[namespace.value]; + const allowedNamespacesNames = allNamespaceAccessReviews + .filter((accessReview) => accessReview.status.allowed) + .map((accessReview) => accessReview.spec.resourceAttributes.namespace); + const allowedNamespaces = namespaceNames.reduce((acc, namespaceName) => { + if (allowedNamespacesNames.includes(namespaceName)) { + acc[namespaceName] = namespaces[namespaceName]; } - }); - return ns; + return acc; + }, {} as Namespaces); + return allowedNamespaces; }, { onError: (err) => { diff --git a/app/react/kubernetes/namespaces/service.ts b/app/react/kubernetes/namespaces/service.ts index 57e91689d..27549c2f3 100644 --- a/app/react/kubernetes/namespaces/service.ts +++ b/app/react/kubernetes/namespaces/service.ts @@ -1,7 +1,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { Namespaces } from './types'; +import { Namespaces, SelfSubjectAccessReviewResponse } from './types'; export async function getNamespace( environmentId: EnvironmentId, @@ -28,6 +28,39 @@ export async function getNamespaces(environmentId: EnvironmentId) { } } +export async function getSelfSubjectAccessReview( + environmentId: EnvironmentId, + namespaceName: string, + verb = 'list', + resource = 'deployments', + group = 'apps' +) { + try { + const { data: accessReview } = + await axios.post( + `endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`, + { + spec: { + resourceAttributes: { + group, + resource, + verb, + namespace: namespaceName, + }, + }, + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectAccessReview', + } + ); + return accessReview; + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve self subject access review' + ); + } +} + function buildUrl(environmentId: EnvironmentId, namespace?: string) { let url = `kubernetes/${environmentId}/namespaces`; diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index 20436c306..86aae0c48 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -4,3 +4,14 @@ export interface Namespaces { IsSystem: boolean; }; } + +export interface SelfSubjectAccessReviewResponse { + status: { + allowed: boolean; + }; + spec: { + resourceAttributes: { + namespace: string; + }; + }; +} diff --git a/app/react/kubernetes/volumes/queries.ts b/app/react/kubernetes/volumes/queries.ts new file mode 100644 index 000000000..c03856416 --- /dev/null +++ b/app/react/kubernetes/volumes/queries.ts @@ -0,0 +1,21 @@ +import { useQuery } from 'react-query'; + +import { withError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { getPVCsForCluster } from './service'; + +// useQuery to get a list of all applications from an array of namespaces +export function usePVCsForCluster( + environemtId: EnvironmentId, + namespaces?: string[] +) { + return useQuery( + ['environments', environemtId, 'kubernetes', 'pvcs'], + () => namespaces && getPVCsForCluster(environemtId, namespaces), + { + ...withError('Unable to retrieve applications'), + enabled: !!namespaces, + } + ); +} diff --git a/app/react/kubernetes/volumes/service.ts b/app/react/kubernetes/volumes/service.ts new file mode 100644 index 000000000..3f79e7014 --- /dev/null +++ b/app/react/kubernetes/volumes/service.ts @@ -0,0 +1,33 @@ +import { PersistentVolumeClaimList } from 'kubernetes-types/core/v1'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function getPVCsForCluster( + environmentId: EnvironmentId, + namespaces: string[] +) { + try { + const pvcs = await Promise.all( + namespaces.map((namespace) => getPVCs(environmentId, namespace)) + ); + return pvcs.flat(); + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve persistent volume claims for cluster' + ); + } +} + +// get all persistent volume claims for a namespace +export async function getPVCs(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/persistentvolumeclaims` + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve deployments'); + } +} diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 639bad9f1..994434545 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -50,8 +50,16 @@ export type IngressClass = { Type: string; }; +interface StorageClass { + Name: string; + AccessModes: string[]; + AllowVolumeExpansion: boolean; + Provisioner: string; +} + export interface KubernetesConfiguration { UseLoadBalancer?: boolean; + StorageClasses?: StorageClass[]; UseServerMetrics?: boolean; EnableResourceOverCommit?: boolean; ResourceOverCommitPercentage?: number; diff --git a/app/setup-tests/server-handlers.ts b/app/setup-tests/server-handlers.ts index 94e1590e9..f520191f8 100644 --- a/app/setup-tests/server-handlers.ts +++ b/app/setup-tests/server-handlers.ts @@ -17,8 +17,8 @@ import { dockerHandlers } from './setup-handlers/docker'; import { userHandlers } from './setup-handlers/users'; const tags: Tag[] = [ - { ID: 1, Name: 'tag1' }, - { ID: 2, Name: 'tag2' }, + { ID: 1, Name: 'tag1', Endpoints: {} }, + { ID: 2, Name: 'tag2', Endpoints: {} }, ]; const licenseInfo: LicenseInfo = { @@ -68,7 +68,7 @@ export const handlers = [ rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))), rest.post<{ name: string }>('/api/tags', (req, res, ctx) => { const tagName = req.body.name; - const tag = { ID: tags.length + 1, Name: tagName }; + const tag = { ID: tags.length + 1, Name: tagName, Endpoints: {} }; tags.push(tag); return res(ctx.json(tag)); }), diff --git a/package.json b/package.json index 38c0187bc..d38131e50 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "html-webpack-plugin": "^5.5.0", "husky": "4.2.5", "jest": "^27.4.3", + "kubernetes-types": "^1.26.0", "lint-staged": ">=10", "load-grunt-tasks": "^3.5.2", "lodash-webpack-plugin": "^0.11.6", diff --git a/yarn.lock b/yarn.lock index 515fb3e5e..9ac79504b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12944,6 +12944,11 @@ klona@^2.0.5: resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc" integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ== +kubernetes-types@^1.26.0: + version "1.26.0" + resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.26.0.tgz#47b7db20eb084931cfebf67937cc6b9091dc3da3" + integrity sha512-jv0XaTIGW/p18jaiKRD85hLTYWx0yEj+cb6PDX3GdNa3dWoRxnD4Gv7+bE6C/ehcsp2skcdy34vT25jbPofDIQ== + kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"