mirror of https://github.com/portainer/portainer
fix(kube): improve dashboard load speed [EE-4941] (#8572)
* apply changes from EE * clear query cache when logging out * Text transitions in smootherpull/8617/head
parent
5f0af62521
commit
89194405ee
|
@ -78,7 +78,7 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<a class="no-link" ui-sref="docker.stacks" ng-if="showStacks">
|
||||||
<dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item>
|
<dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -46,8 +46,6 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) {
|
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.');
|
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) {
|
} catch (e) {
|
||||||
let params = {};
|
let params = {};
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
||||||
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
||||||
|
import { DashboardView } from '@/react/kubernetes/DashboardView';
|
||||||
import { ServicesView } from '@/react/kubernetes/ServicesView';
|
import { ServicesView } from '@/react/kubernetes/ServicesView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
|
@ -24,4 +25,8 @@ export const viewsModule = angular
|
||||||
.component(
|
.component(
|
||||||
'kubernetesIngressesCreateView',
|
'kubernetesIngressesCreateView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'kubernetesDashboardView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -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>
|
|
|
@ -1,8 +0,0 @@
|
||||||
angular.module('portainer.kubernetes').component('kubernetesDashboardView', {
|
|
||||||
templateUrl: './dashboard.html',
|
|
||||||
controller: 'KubernetesDashboardController',
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
bindings: {
|
|
||||||
endpoint: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -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);
|
|
|
@ -121,7 +121,17 @@ export const componentsModule = angular
|
||||||
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
|
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
|
||||||
.component(
|
.component(
|
||||||
'dashboardItem',
|
'dashboardItem',
|
||||||
r2a(DashboardItem, ['icon', 'type', 'value', 'children'])
|
r2a(DashboardItem, [
|
||||||
|
'icon',
|
||||||
|
'type',
|
||||||
|
'value',
|
||||||
|
'to',
|
||||||
|
'children',
|
||||||
|
'pluralType',
|
||||||
|
'isLoading',
|
||||||
|
'isRefetching',
|
||||||
|
'dataCy',
|
||||||
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
'datatableSearchbar',
|
'datatableSearchbar',
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
withError,
|
withError,
|
||||||
withInvalidate,
|
withInvalidate,
|
||||||
} from '@/react-tools/react-query';
|
} from '@/react-tools/react-query';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { createTag, getTags } from './tags.service';
|
import { createTag, getTags } from './tags.service';
|
||||||
import { Tag, TagId } from './types';
|
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() {
|
export function useCreateTagMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
|
@ -3,4 +3,5 @@ export type TagId = number;
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
ID: TagId;
|
ID: TagId;
|
||||||
Name: string;
|
Name: string;
|
||||||
|
Endpoints: Record<number, boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,12 +34,15 @@ export function DashboardView() {
|
||||||
<DashboardGrid>
|
<DashboardGrid>
|
||||||
<DashboardItem
|
<DashboardItem
|
||||||
value={subscriptionsCount as number}
|
value={subscriptionsCount as number}
|
||||||
|
isLoading={subscriptionsQuery.isLoading}
|
||||||
|
isRefetching={subscriptionsQuery.isRefetching}
|
||||||
icon={Subscription}
|
icon={Subscription}
|
||||||
type="Subscription"
|
type="Subscription"
|
||||||
/>
|
/>
|
||||||
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
|
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
|
||||||
<DashboardItem
|
<DashboardItem
|
||||||
value={resourceGroupsCount}
|
value={resourceGroupsCount}
|
||||||
|
isLoading={resourceGroupsQuery.isLoading}
|
||||||
icon={Package}
|
icon={Package}
|
||||||
type="Resource group"
|
type="Resource group"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
.dashboard-grid {
|
|
||||||
@apply grid grid-cols-2 gap-3;
|
|
||||||
}
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import './DashboardGrid.css';
|
|
||||||
|
|
||||||
export function DashboardGrid({ children }: PropsWithChildren<unknown>) {
|
export function DashboardGrid({ children }: PropsWithChildren<unknown>) {
|
||||||
return <div className="dashboard-grid">{children}</div>;
|
return <div className="grid grid-cols-2 gap-3">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,62 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { Icon, IconProps } from '@/react/components/Icon';
|
import { Icon, IconProps } from '@/react/components/Icon';
|
||||||
import { pluralize } from '@/portainer/helpers/strings';
|
import { pluralize } from '@/portainer/helpers/strings';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
interface Props extends IconProps {
|
interface Props extends IconProps {
|
||||||
value?: number;
|
|
||||||
type: string;
|
type: string;
|
||||||
|
pluralType?: string; // in case the pluralise function isn't suitable
|
||||||
|
isLoading?: boolean;
|
||||||
|
isRefetching?: boolean;
|
||||||
|
value?: number;
|
||||||
|
to?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
dataCy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardItem({ value, icon, type, children }: Props) {
|
export function DashboardItem({
|
||||||
return (
|
icon,
|
||||||
|
type,
|
||||||
|
pluralType,
|
||||||
|
isLoading,
|
||||||
|
isRefetching,
|
||||||
|
value,
|
||||||
|
to,
|
||||||
|
children,
|
||||||
|
dataCy,
|
||||||
|
}: Props) {
|
||||||
|
const Item = (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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',
|
'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-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'
|
'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="flex items-center" aria-label={type}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -42,7 +79,7 @@ export function DashboardItem({ value, icon, type, children }: Props) {
|
||||||
)}
|
)}
|
||||||
aria-label="value"
|
aria-label="value"
|
||||||
>
|
>
|
||||||
{typeof value !== 'undefined' ? value : '-'}
|
{typeof value === 'undefined' ? '-' : value}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -53,7 +90,7 @@ export function DashboardItem({ value, icon, type, children }: Props) {
|
||||||
)}
|
)}
|
||||||
aria-label="resourceType"
|
aria-label="resourceType"
|
||||||
>
|
>
|
||||||
{pluralize(value || 0, type)}
|
{pluralize(value || 0, type, pluralType)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -61,4 +98,13 @@ export function DashboardItem({ value, icon, type, children }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className="!no-underline">
|
||||||
|
{Item}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Item;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
.reloadButton {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
|
@ -7,7 +7,6 @@ import { Breadcrumbs } from './Breadcrumbs';
|
||||||
import { Crumb } from './Breadcrumbs/Breadcrumbs';
|
import { Crumb } from './Breadcrumbs/Breadcrumbs';
|
||||||
import { HeaderContainer } from './HeaderContainer';
|
import { HeaderContainer } from './HeaderContainer';
|
||||||
import { HeaderTitle } from './HeaderTitle';
|
import { HeaderTitle } from './HeaderTitle';
|
||||||
import styles from './PageHeader.module.css';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -42,7 +41,7 @@ export function PageHeader({
|
||||||
color="none"
|
color="none"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={onClickedRefresh}
|
onClick={onClickedRefresh}
|
||||||
className={styles.reloadButton}
|
className="m-0 p-0 focus:text-inherit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw className="icon" />
|
<RefreshCw className="icon" />
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { UISrefProps, useSref } from '@uirouter/react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { User, ChevronDown } from 'lucide-react';
|
import { User, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { queryClient } from '@/react-tools/react-query';
|
||||||
import { AutomationTestingProps } from '@/types';
|
import { AutomationTestingProps } from '@/types';
|
||||||
import { useUser } from '@/react/hooks/useUser';
|
import { useUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
@ -78,7 +79,10 @@ function MenuLink({
|
||||||
return (
|
return (
|
||||||
<ReachMenuLink
|
<ReachMenuLink
|
||||||
href={anchorProps.href}
|
href={anchorProps.href}
|
||||||
onClick={anchorProps.onClick}
|
onClick={(e) => {
|
||||||
|
queryClient.clear();
|
||||||
|
anchorProps.onClick(e);
|
||||||
|
}}
|
||||||
className={styles.menuLink}
|
className={styles.menuLink}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
|
|
|
@ -19,10 +19,12 @@ test('should show the selected tags', async () => {
|
||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Name: 'tag1',
|
Name: 'tag1',
|
||||||
|
Endpoints: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Name: 'tag2',
|
Name: 'tag2',
|
||||||
|
Endpoints: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { DashboardView } from './DashboardView';
|
|
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -2,9 +2,11 @@ import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { error as notifyError } from '@/portainer/services/notifications';
|
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(
|
export function useConfigurations(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace?: string
|
namespace?: string
|
||||||
|
@ -18,7 +20,7 @@ export function useConfigurations(
|
||||||
namespace,
|
namespace,
|
||||||
'configurations',
|
'configurations',
|
||||||
],
|
],
|
||||||
() => (namespace ? getConfigMaps(environmentId, namespace) : []),
|
() => (namespace ? getConfigurations(environmentId, namespace) : []),
|
||||||
{
|
{
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
notifyError('Failure', err as Error, 'Unable to get configurations');
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { Configuration } from './types';
|
import { Configuration } from './types';
|
||||||
|
|
||||||
export async function getConfigMaps(
|
// returns the formatted list of configmaps and secrets
|
||||||
|
export async function getConfigurations(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string
|
namespace: string
|
||||||
) {
|
) {
|
||||||
|
@ -16,3 +17,20 @@ export async function getConfigMaps(
|
||||||
throw parseAxiosError(e as Error, 'Unable to retrieve configmaps');
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,9 +3,11 @@ import { useQuery } from 'react-query';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { error as notifyError } from '@/portainer/services/notifications';
|
import { error as notifyError } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
import { getIngresses } from '../ingresses/service';
|
import {
|
||||||
|
getNamespaces,
|
||||||
import { getNamespaces, getNamespace } from './service';
|
getNamespace,
|
||||||
|
getSelfSubjectAccessReview,
|
||||||
|
} from './service';
|
||||||
import { Namespaces } from './types';
|
import { Namespaces } from './types';
|
||||||
|
|
||||||
export function useNamespaces(environmentId: EnvironmentId) {
|
export function useNamespaces(environmentId: EnvironmentId) {
|
||||||
|
@ -13,18 +15,23 @@ export function useNamespaces(environmentId: EnvironmentId) {
|
||||||
['environments', environmentId, 'kubernetes', 'namespaces'],
|
['environments', environmentId, 'kubernetes', 'namespaces'],
|
||||||
async () => {
|
async () => {
|
||||||
const namespaces = await getNamespaces(environmentId);
|
const namespaces = await getNamespaces(environmentId);
|
||||||
const settledNamespacesPromise = await Promise.allSettled(
|
const namespaceNames = Object.keys(namespaces);
|
||||||
Object.keys(namespaces).map((namespace) =>
|
// use seflsubjectaccess reviews to avoid forbidden requests
|
||||||
getIngresses(environmentId, namespace).then(() => namespace)
|
const allNamespaceAccessReviews = await Promise.all(
|
||||||
|
namespaceNames.map((namespaceName) =>
|
||||||
|
getSelfSubjectAccessReview(environmentId, namespaceName)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const ns: Namespaces = {};
|
const allowedNamespacesNames = allNamespaceAccessReviews
|
||||||
settledNamespacesPromise.forEach((namespace) => {
|
.filter((accessReview) => accessReview.status.allowed)
|
||||||
if (namespace.status === 'fulfilled') {
|
.map((accessReview) => accessReview.spec.resourceAttributes.namespace);
|
||||||
ns[namespace.value] = namespaces[namespace.value];
|
const allowedNamespaces = namespaceNames.reduce((acc, namespaceName) => {
|
||||||
|
if (allowedNamespacesNames.includes(namespaceName)) {
|
||||||
|
acc[namespaceName] = namespaces[namespaceName];
|
||||||
}
|
}
|
||||||
});
|
return acc;
|
||||||
return ns;
|
}, {} as Namespaces);
|
||||||
|
return allowedNamespaces;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { Namespaces } from './types';
|
import { Namespaces, SelfSubjectAccessReviewResponse } from './types';
|
||||||
|
|
||||||
export async function getNamespace(
|
export async function getNamespace(
|
||||||
environmentId: EnvironmentId,
|
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) {
|
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||||
let url = `kubernetes/${environmentId}/namespaces`;
|
let url = `kubernetes/${environmentId}/namespaces`;
|
||||||
|
|
||||||
|
|
|
@ -4,3 +4,14 @@ export interface Namespaces {
|
||||||
IsSystem: boolean;
|
IsSystem: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelfSubjectAccessReviewResponse {
|
||||||
|
status: {
|
||||||
|
allowed: boolean;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
resourceAttributes: {
|
||||||
|
namespace: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,8 +50,16 @@ export type IngressClass = {
|
||||||
Type: string;
|
Type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface StorageClass {
|
||||||
|
Name: string;
|
||||||
|
AccessModes: string[];
|
||||||
|
AllowVolumeExpansion: boolean;
|
||||||
|
Provisioner: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KubernetesConfiguration {
|
export interface KubernetesConfiguration {
|
||||||
UseLoadBalancer?: boolean;
|
UseLoadBalancer?: boolean;
|
||||||
|
StorageClasses?: StorageClass[];
|
||||||
UseServerMetrics?: boolean;
|
UseServerMetrics?: boolean;
|
||||||
EnableResourceOverCommit?: boolean;
|
EnableResourceOverCommit?: boolean;
|
||||||
ResourceOverCommitPercentage?: number;
|
ResourceOverCommitPercentage?: number;
|
||||||
|
|
|
@ -17,8 +17,8 @@ import { dockerHandlers } from './setup-handlers/docker';
|
||||||
import { userHandlers } from './setup-handlers/users';
|
import { userHandlers } from './setup-handlers/users';
|
||||||
|
|
||||||
const tags: Tag[] = [
|
const tags: Tag[] = [
|
||||||
{ ID: 1, Name: 'tag1' },
|
{ ID: 1, Name: 'tag1', Endpoints: {} },
|
||||||
{ ID: 2, Name: 'tag2' },
|
{ ID: 2, Name: 'tag2', Endpoints: {} },
|
||||||
];
|
];
|
||||||
|
|
||||||
const licenseInfo: LicenseInfo = {
|
const licenseInfo: LicenseInfo = {
|
||||||
|
@ -68,7 +68,7 @@ export const handlers = [
|
||||||
rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))),
|
rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))),
|
||||||
rest.post<{ name: string }>('/api/tags', (req, res, ctx) => {
|
rest.post<{ name: string }>('/api/tags', (req, res, ctx) => {
|
||||||
const tagName = req.body.name;
|
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);
|
tags.push(tag);
|
||||||
return res(ctx.json(tag));
|
return res(ctx.json(tag));
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -233,6 +233,7 @@
|
||||||
"html-webpack-plugin": "^5.5.0",
|
"html-webpack-plugin": "^5.5.0",
|
||||||
"husky": "4.2.5",
|
"husky": "4.2.5",
|
||||||
"jest": "^27.4.3",
|
"jest": "^27.4.3",
|
||||||
|
"kubernetes-types": "^1.26.0",
|
||||||
"lint-staged": ">=10",
|
"lint-staged": ">=10",
|
||||||
"load-grunt-tasks": "^3.5.2",
|
"load-grunt-tasks": "^3.5.2",
|
||||||
"lodash-webpack-plugin": "^0.11.6",
|
"lodash-webpack-plugin": "^0.11.6",
|
||||||
|
|
|
@ -12944,6 +12944,11 @@ klona@^2.0.5:
|
||||||
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc"
|
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc"
|
||||||
integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==
|
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:
|
kuler@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
|
resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
|
||||||
|
|
Loading…
Reference in New Issue