fix(kube): improve dashboard load speed [EE-4941] (#8572)

* apply changes from EE

* clear query cache when logging out

* Text transitions in smoother
pull/8617/head
Ali 2023-03-08 11:22:08 +13:00 committed by GitHub
parent 5f0af62521
commit 89194405ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 569 additions and 210 deletions

View File

@ -78,7 +78,7 @@
</div>
</div>
<div class="dashboard-grid mx-4">
<div class="mx-4 grid grid-cols-2 gap-3">
<a class="no-link" ui-sref="docker.stacks" ng-if="showStacks">
<dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item>
</a>

View File

@ -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 = {};

View File

@ -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;

View File

@ -1,64 +0,0 @@
<page-header ng-if="ctrl.state.viewReady" title="'Dashboard'" breadcrumbs="['Environment summary']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row" ng-if="ctrl.endpoint">
<div class="col-sm-12">
<rd-widget>
<div class="toolBar vertical-center w-full">
<div class="toolBarTitle vertical-center p-5">
<div class="widget-icon space-right">
<pr-icon icon="'gauge'"></pr-icon>
</div>
Environment info
</div>
</div>
<rd-widget-body classes="!px-5 !py-0">
<table class="table">
<tbody>
<tr>
<td class="!border-none !pl-0">Environment</td>
<td class="!border-none">
{{ ctrl.endpoint.Name }}
</td>
</tr>
<tr ng-if="ctrl.showEnvUrl">
<td class="!border-t !pl-0">URL</td>
<td class="!border-t">{{ ctrl.endpoint.URL | stripprotocol }}</td>
</tr>
<tr>
<td class="!pl-0">Tags</td>
<td>{{ ctrl.endpointTags }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="dashboard-grid mx-4">
<div ng-if="ctrl.pools" data-cy="k8sDashboard-namespaces">
<a class="no-link" ui-sref="kubernetes.resourcePools">
<dashboard-item icon="'layers'" type="'Namespace'" value="ctrl.pools.length"></dashboard-item>
</a>
</div>
<div ng-if="ctrl.applications" data-cy="k8sDashboard-applications">
<a class="no-link" ui-sref="kubernetes.applications">
<dashboard-item icon="'box'" type="'Application'" value="ctrl.applications.length"></dashboard-item>
</a>
</div>
<div ng-if="ctrl.configurations" data-cy="k8sDashboard-configurations">
<a class="no-link" ui-sref="kubernetes.configurations">
<dashboard-item icon="'lock'" type="'ConfigMaps & Secret'" value="ctrl.configurations.length"></dashboard-item>
</a>
</div>
<div ng-if="ctrl.volumes" data-cy="k8sDashboard-volumes">
<a class="no-link" ui-sref="kubernetes.volumes">
<dashboard-item icon="'database'" type="'Volume'" value="ctrl.volumes.length"></dashboard-item>
</a>
</div>
</div>
</div>

View File

@ -1,8 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesDashboardView', {
templateUrl: './dashboard.html',
controller: 'KubernetesDashboardController',
controllerAs: 'ctrl',
bindings: {
endpoint: '<',
},
});

View File

@ -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);

View File

@ -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',

View File

@ -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<T = Tag[]>({
});
}
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();

View File

@ -3,4 +3,5 @@ export type TagId = number;
export interface Tag {
ID: TagId;
Name: string;
Endpoints: Record<number, boolean>;
}

View File

@ -34,12 +34,15 @@ export function DashboardView() {
<DashboardGrid>
<DashboardItem
value={subscriptionsCount as number}
isLoading={subscriptionsQuery.isLoading}
isRefetching={subscriptionsQuery.isRefetching}
icon={Subscription}
type="Subscription"
/>
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
<DashboardItem
value={resourceGroupsCount}
isLoading={resourceGroupsQuery.isLoading}
icon={Package}
type="Resource group"
/>

View File

@ -1,3 +0,0 @@
.dashboard-grid {
@apply grid grid-cols-2 gap-3;
}

View File

@ -1,7 +1,5 @@
import { PropsWithChildren } from 'react';
import './DashboardGrid.css';
export function DashboardGrid({ children }: PropsWithChildren<unknown>) {
return <div className="dashboard-grid">{children}</div>;
return <div className="grid grid-cols-2 gap-3">{children}</div>;
}

View File

@ -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 = (
<div
className={clsx(
'rounded-lg border border-solid p-3',
'relative rounded-lg border border-solid p-3',
'border-gray-5 bg-gray-2 hover:border-blue-7 hover:bg-blue-2',
'th-dark:border-gray-neutral-8 th-dark:bg-gray-iron-10 th-dark:hover:border-blue-8 th-dark:hover:bg-gray-10',
'th-highcontrast:border-white th-highcontrast:bg-black th-highcontrast:hover:border-blue-8 th-highcontrast:hover:bg-gray-11'
)}
data-cy={dataCy}
>
<div
className={clsx(
'text-muted absolute top-2 right-2 flex items-center transition-opacity',
isRefetching ? 'opacity-100' : 'opacity-0'
)}
>
Refreshing total
<Loader2 className="h-4 animate-spin-slow" />
</div>
<div
className={clsx(
'text-muted absolute top-2 right-2 flex items-center transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0'
)}
>
Loading total
<Loader2 className="h-4 animate-spin-slow" />
</div>
<div className="flex items-center" aria-label={type}>
<div
className={clsx(
@ -42,7 +79,7 @@ export function DashboardItem({ value, icon, type, children }: Props) {
)}
aria-label="value"
>
{typeof value !== 'undefined' ? value : '-'}
{typeof value === 'undefined' ? '-' : value}
</div>
<div
className={clsx(
@ -53,7 +90,7 @@ export function DashboardItem({ value, icon, type, children }: Props) {
)}
aria-label="resourceType"
>
{pluralize(value || 0, type)}
{pluralize(value || 0, type, pluralType)}
</div>
</div>
@ -61,4 +98,13 @@ export function DashboardItem({ value, icon, type, children }: Props) {
</div>
</div>
);
if (to) {
return (
<Link to={to} className="!no-underline">
{Item}
</Link>
);
}
return Item;
}

View File

@ -1,4 +0,0 @@
.reloadButton {
padding: 0;
margin: 0;
}

View File

@ -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}
>
<RefreshCw className="icon" />

View File

@ -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 (
<ReachMenuLink
href={anchorProps.href}
onClick={anchorProps.onClick}
onClick={(e) => {
queryClient.clear();
anchorProps.onClick(e);
}}
className={styles.menuLink}
aria-label={label}
data-cy={dataCy}

View File

@ -19,10 +19,12 @@ test('should show the selected tags', async () => {
{
ID: 1,
Name: 'tag1',
Endpoints: {},
},
{
ID: 2,
Name: 'tag2',
Endpoints: {},
},
];

View File

@ -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 (
<>
<PageHeader
title="Dashboard"
breadcrumbs={[{ label: 'Environment summary' }]}
reload
onReload={() =>
queryClient.invalidateQueries(['environments', environmentId])
}
/>
<div className="col-sm-12 flex flex-col gap-y-5">
<EnvironmentInfo />
<DashboardGrid>
<DashboardItem
value={namespaceNames?.length}
isLoading={namespacesQuery.isLoading}
isRefetching={namespacesQuery.isRefetching}
icon={Layers}
to="kubernetes.resourcePools"
type="Namespace"
dataCy="dashboard-namespace"
/>
<DashboardItem
value={applications?.length}
isLoading={applicationsQuery.isLoading || namespacesQuery.isLoading}
isRefetching={
applicationsQuery.isRefetching || namespacesQuery.isRefetching
}
icon={Box}
to="kubernetes.applications"
type="Application"
dataCy="dashboard-application"
/>
<DashboardItem
value={configurations?.length}
isLoading={
configurationsQuery.isLoading || namespacesQuery.isLoading
}
isRefetching={
configurationsQuery.isRefetching || namespacesQuery.isRefetching
}
icon={Lock}
to="kubernetes.configurations"
type="ConfigMaps & Secrets"
pluralType="ConfigMaps & Secrets"
dataCy="dashboard-config"
/>
<DashboardItem
value={pvcs?.length}
isLoading={pvcsQuery.isLoading || namespacesQuery.isLoading}
isRefetching={
pvcsQuery.isRefetching || namespacesQuery.isRefetching
}
icon={Database}
to="kubernetes.volumes"
type="Volume"
dataCy="dashboard-volume"
/>
</DashboardGrid>
</div>
</>
);
}

View File

@ -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 (
<Widget>
<WidgetTitle icon={Gauge} title="Environment info" />
<WidgetBody loading={environmentQuery.isLoading}>
{environmentQuery.isError && <div>Failed to load environment</div>}
{environmentData && (
<table className="table">
<tbody>
<tr>
<td className="!border-none !pl-0">Environment</td>
<td
className="!border-none"
data-cy="dashboard-environmentName"
>
{environmentData.Name}
</td>
</tr>
<tr ng-if="ctrl.showEnvUrl">
<td className="!border-t !pl-0">URL</td>
<td className="!border-t" data-cy="dashboard-environmenturl">
{stripProtocol(environmentData.URL) || '-'}
</td>
</tr>
<tr>
<td className="!pl-0">Tags</td>
<td data-cy="dashboard-environmentTags">{tagNames}</td>
</tr>
</tbody>
</table>
)}
</WidgetBody>
</Widget>
);
}

View File

@ -0,0 +1 @@
export { DashboardView } from './DashboardView';

View File

@ -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,
}
);
}

View File

@ -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<DeploymentList>(
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<DaemonSetList>(
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<StatefulSetList>(
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<PodList>(
`/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;
}

View File

@ -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,
}
);
}

View File

@ -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'
);
}
}

View File

@ -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) => {

View File

@ -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<SelfSubjectAccessReviewResponse>(
`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`;

View File

@ -4,3 +4,14 @@ export interface Namespaces {
IsSystem: boolean;
};
}
export interface SelfSubjectAccessReviewResponse {
status: {
allowed: boolean;
};
spec: {
resourceAttributes: {
namespace: string;
};
};
}

View File

@ -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,
}
);
}

View File

@ -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<PersistentVolumeClaimList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/persistentvolumeclaims`
);
return data.items;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve deployments');
}
}

View File

@ -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;

View File

@ -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));
}),

View File

@ -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",

View File

@ -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"