mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +02:00
fix(kube): improve dashboard load speed [EE-4941] (#8572)
* apply changes from EE * clear query cache when logging out * Text transitions in smoother
This commit is contained in:
parent
5f0af62521
commit
89194405ee
36 changed files with 569 additions and 210 deletions
93
app/react/kubernetes/DashboardView/DashboardView.tsx
Normal file
93
app/react/kubernetes/DashboardView/DashboardView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
50
app/react/kubernetes/DashboardView/EnvironmentInfo.tsx
Normal file
50
app/react/kubernetes/DashboardView/EnvironmentInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/kubernetes/DashboardView/index.ts
Normal file
1
app/react/kubernetes/DashboardView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { DashboardView } from './DashboardView';
|
21
app/react/kubernetes/applications/queries.ts
Normal file
21
app/react/kubernetes/applications/queries.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
141
app/react/kubernetes/applications/service.ts
Normal file
141
app/react/kubernetes/applications/service.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
21
app/react/kubernetes/volumes/queries.ts
Normal file
21
app/react/kubernetes/volumes/queries.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
33
app/react/kubernetes/volumes/service.ts
Normal file
33
app/react/kubernetes/volumes/service.ts
Normal 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');
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue