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 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>
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
'dashboardItem',
|
||||
r2a(DashboardItem, ['icon', 'type', 'value', 'children'])
|
||||
r2a(DashboardItem, [
|
||||
'icon',
|
||||
'type',
|
||||
'value',
|
||||
'to',
|
||||
'children',
|
||||
'pluralType',
|
||||
'isLoading',
|
||||
'isRefetching',
|
||||
'dataCy',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'datatableSearchbar',
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -3,4 +3,5 @@ export type TagId = number;
|
|||
export interface Tag {
|
||||
ID: TagId;
|
||||
Name: string;
|
||||
Endpoints: Record<number, boolean>;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.dashboard-grid {
|
||||
@apply grid grid-cols-2 gap-3;
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
.reloadButton {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -19,10 +19,12 @@ test('should show the selected tags', async () => {
|
|||
{
|
||||
ID: 1,
|
||||
Name: 'tag1',
|
||||
Endpoints: {},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
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 { 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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`;
|
||||
|
||||
|
|
|
@ -4,3 +4,14 @@ export interface Namespaces {
|
|||
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;
|
||||
};
|
||||
|
||||
interface StorageClass {
|
||||
Name: string;
|
||||
AccessModes: string[];
|
||||
AllowVolumeExpansion: boolean;
|
||||
Provisioner: string;
|
||||
}
|
||||
|
||||
export interface KubernetesConfiguration {
|
||||
UseLoadBalancer?: boolean;
|
||||
StorageClasses?: StorageClass[];
|
||||
UseServerMetrics?: boolean;
|
||||
EnableResourceOverCommit?: boolean;
|
||||
ResourceOverCommitPercentage?: number;
|
||||
|
|
|
@ -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));
|
||||
}),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue