refactor(app): summary widget migration [EE-5351] (#8796)

* refactor(app): summary widget migration [EE-5351]

* update converter and limit display

---------

Co-authored-by: testa113 <testa113>
pull/8797/head
Ali 2023-05-03 15:55:25 +12:00 committed by GitHub
parent 745bbb7d79
commit 98e6393274
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 964 additions and 304 deletions

View File

@ -151,7 +151,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const application = {
name: 'kubernetes.applications.application',
url: '/:namespace/:name',
url: '/:namespace/:name?resource-type',
views: {
'content@': {
component: 'kubernetesApplicationView',

View File

@ -276,7 +276,7 @@
</a>
<a
ng-if="!item.KubernetesApplications"
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })"
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool, 'resource-type': $ctrl.applicationTypeEnumToParamMap[item.ApplicationType] })"
ng-click="$event.stopPropagation()"
class="hyperlink"
>{{ item.Name }}

View File

@ -33,6 +33,13 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
},
};
this.applicationTypeEnumToParamMap = {
[KubernetesApplicationTypes.DEPLOYMENT]: 'Deployment',
[KubernetesApplicationTypes.DAEMONSET]: 'DaemonSet',
[KubernetesApplicationTypes.STATEFULSET]: 'StatefulSet',
[KubernetesApplicationTypes.POD]: 'Pod',
};
this.expandAll = function () {
this.state.expandAll = !this.state.expandAll;
this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll));

View File

@ -8,6 +8,10 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
import { ApplicationSummaryWidget } from '@/react/kubernetes/applications/DetailsView';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
export const componentsModule = angular
.module('portainer.kubernetes.react.components', [])
@ -82,4 +86,11 @@ export const componentsModule = angular
'onChange',
'supportGlobalDeployment',
])
)
.component(
'applicationSummaryWidget',
r2a(
withUIRouter(withReactQuery(withUserProvider(ApplicationSummaryWidget))),
[]
)
).name;

View File

@ -25,133 +25,7 @@
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading> <pr-icon icon="'svg-laptopcode'" class-name="'mr-1'"></pr-icon> Application </uib-tab-heading>
<div style="padding: 20px">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td data-cy="k8sAppDetail-appName">
{{ ctrl.application.Name }}
<span class="label label-primary image-tag label-margins" ng-if="!ctrl.isSystemNamespace() && ctrl.isExternalApplication()">external</span>
</td>
</tr>
<tr>
<td>Stack</td>
<td data-cy="k8sAppDetail-stackName">{{ ctrl.application.StackName || '-' }}</td>
</tr>
<tr>
<td>Namespace</td>
<td data-cy="k8sAppDetail-resourcePoolName">
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.application.ResourcePool })">{{ ctrl.application.ResourcePool }}</a>
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace()">system</span>
</td>
</tr>
<tr>
<td>Application Type</td>
<td data-cy="k8sAppDetail-appType">
{{ ctrl.application.ApplicationType | kubernetesApplicationTypeText }}
</td>
</tr>
<tr>
<td>Status</td>
<td ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD">
<span ng-if="ctrl.application.DeploymentType === ctrl.KubernetesApplicationDeploymentTypes.REPLICATED" data-cy="k8sAppDetail-deployType">Replicated</span>
<span ng-if="ctrl.application.DeploymentType === ctrl.KubernetesApplicationDeploymentTypes.GLOBAL" data-cy="k8sAppDetail-appType">Global</span>
<code data-cy="k8sAppDetail-runningPods">{{ ctrl.application.RunningPodsCount }}</code> /
<code data-cy="k8sAppDetail-totalPods">{{ ctrl.application.TotalPodsCount }}</code>
</td>
<td ng-if="ctrl.application.ApplicationType === ctrl.KubernetesApplicationTypes.POD">
{{ ctrl.application.Pods[0].Status }}
</td>
</tr>
<tr ng-if="ctrl.application.Requests.Cpu || ctrl.application.Requests.Memory">
<td>
<div>Resource reservations</div>
<div ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD" class="text-muted small"> per instance </div>
</td>
<td>
<div ng-if="ctrl.application.Requests.Cpu" data-cy="k8sAppDetail-cpuReservation"
>CPU {{ ctrl.application.Requests.Cpu | kubernetesApplicationCPUValue }}</div
>
<div ng-if="ctrl.application.Requests.Memory" data-cy="k8sAppDetail-memoryReservation">Memory {{ ctrl.application.Requests.Memory | humansize }}</div>
</td>
</tr>
<tr>
<td>Creation</td>
<td>
<span ng-if="ctrl.application.ApplicationOwner" class="vertical-center mr-1" data-cy="k8sAppDetail-owner">
<pr-icon icon="'user'"></pr-icon> {{ ctrl.application.ApplicationOwner }}
</span>
<span class="vertical-center"> <pr-icon icon="'clock'" mode="'alt'"></pr-icon> {{ ctrl.application.CreationDate | getisodate }} </span>
<span ng-if="ctrl.application.ApplicationOwner" data-cy="k8sAppDetail-creationMethod" class="vertical-center">
<pr-icon icon="'clock'"></pr-icon> Deployed from {{ ctrl.state.appType }}
</span>
</td>
</tr>
<tr>
<td colspan="2">
<form class="form-horizontal" name="kubernetesApplicationNoteForm">
<div class="form-group">
<div class="col-sm-12 vertical-center">
<pr-icon icon="'edit'"></pr-icon> Note
<button
class="btn btn-xs btn-light vertical-center"
ng-click="ctrl.state.expandedNote = !ctrl.state.expandedNote;"
data-cy="k8sAppDetail-expandNoteButton"
>{{ ctrl.state.expandedNote ? 'Collapse' : 'Expand' }}
<pr-icon icon="'chevron-up'" ng-if="ctrl.state.expandedNote"></pr-icon>
<pr-icon icon="'chevron-down'" ng-if="!ctrl.state.expandedNote"></pr-icon>
</button>
</div>
</div>
<div class="form-group" ng-if="ctrl.state.expandedNote">
<div class="col-sm-12">
<textarea
class="form-control"
name="application_note"
id="application_note"
ng-model="ctrl.formValues.Note"
rows="5"
placeholder="Enter a note about this application..."
></textarea>
</div>
</div>
<div class="form-group" ng-if="ctrl.state.expandedNote">
<div class="col-sm-12">
<button
class="btn btn-primary btn-sm"
style="margin-left: 0px"
type="button"
ng-click="ctrl.updateApplication()"
ng-disabled="ctrl.formValues.Note === ctrl.application.Note"
data-cy="k8sAppDetail-saveNoteButton"
>{{ ctrl.application.Note ? 'Update' : 'Save' }} note</button
>
</div>
</div>
</form>
</td>
</tr>
<!-- <tr>
<td colspan="2">
<form class="form-horizontal" name="KubernetesApplicationRollbackForm">
<div class="form-group">
<label for="resource-pool-selector" class="col-sm-2 col-lg-1 control-label text-left">Version</label>
<div class="col-sm-2">
<select class="form-control" id="resource-pool-selector" ng-model="ctrl.formValues.SelectedRevision"
ng-options="revision as revision.revision for revision in ctrl.application.Revisions"></select>
</div>
<div class="col-sm-2">
<button class="btn btn-primary btn-sm" style="margin-left: 0px;" type="button" ng-click="ctrl.rollbackApplication()"
ng-disabled="ctrl.formValues.SelectedRevision.revision === ctrl.application.CurrentRevision.revision">Rollback</button>
</div>
</div>
</form>
</td>
</tr> -->
</tbody>
</table>
</div>
<application-summary-widget></application-summary-widget>
</uib-tab>
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">

View File

@ -8,7 +8,7 @@ import { DashboardItem } from '@@/DashboardItem/DashboardItem';
import { PageHeader } from '@@/PageHeader';
import { useNamespaces } from '../namespaces/queries';
import { useApplicationsForCluster } from '../applications/queries';
import { useApplicationsForCluster } from '../applications/application.queries';
import { useConfigurationsForCluster } from '../configs/queries';
import { usePVCsForCluster } from '../volumes/queries';

View File

@ -4,6 +4,7 @@ import { compact } from 'lodash';
import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/react/utils';
import { getNamespaces } from '../namespaces/service';
@ -52,12 +53,6 @@ export function useServices(environmentId: EnvironmentId) {
);
}
function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}
export function useMutationDeleteServices(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation(deleteServices, {

View File

@ -0,0 +1,289 @@
import { User, Clock, Edit, ChevronDown, ChevronUp } from 'lucide-react';
import moment from 'moment';
import { useState } from 'react';
import { Pod } from 'kubernetes-types/core/v1';
import { useCurrentStateAndParams } from '@uirouter/react';
import { Authorized } from '@/react/hooks/useUser';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { DetailsTable } from '@@/DetailsTable';
import { Badge } from '@@/Badge';
import { Link } from '@@/Link';
import { Button, LoadingButton } from '@@/buttons';
import { isSystemNamespace } from '../../namespaces/utils';
import {
appStackNameLabel,
appKindToDeploymentTypeMap,
appOwnerLabel,
appDeployMethodLabel,
appNoteAnnotation,
} from '../constants';
import {
applicationIsKind,
bytesToReadableFormat,
getResourceRequests,
getRunningPods,
getTotalPods,
isExternalApplication,
} from '../utils';
import {
useApplication,
usePatchApplicationMutation,
} from '../application.queries';
import { Application } from '../types';
export function ApplicationSummaryWidget() {
const stateAndParams = useCurrentStateAndParams();
const {
params: {
namespace,
name,
'resource-type': resourceType,
endpointId: environmentId,
},
} = stateAndParams;
const applicationQuery = useApplication(
environmentId,
namespace,
name,
resourceType
);
const application = applicationQuery.data;
const systemNamespace = isSystemNamespace(namespace);
const externalApplication = application && isExternalApplication(application);
const applicationRequests = application && getResourceRequests(application);
const applicationOwner = application?.metadata?.labels?.[appOwnerLabel];
const applicationDeployMethod = getApplicationDeployMethod(application);
const applicationNote =
application?.metadata?.annotations?.[appNoteAnnotation];
const [isNoteOpen, setIsNoteOpen] = useState(true);
const [applicationNoteFormValues, setApplicationNoteFormValues] = useState(
applicationNote || ''
);
const patchApplicationMutation = usePatchApplicationMutation(
environmentId,
namespace,
name
);
return (
<div className="p-5">
<DetailsTable>
<tr>
<td>Name</td>
<td>
<div
className="flex items-center gap-x-2"
data-cy="k8sAppDetail-appName"
>
{name}
{externalApplication && !systemNamespace && (
<Badge type="info">external</Badge>
)}
</div>
</td>
</tr>
<tr>
<td>Stack</td>
<td data-cy="k8sAppDetail-stackName">
{application?.metadata?.labels?.[appStackNameLabel] || '-'}
</td>
</tr>
<tr>
<td>Namespace</td>
<td>
<div
className="flex items-center gap-x-2"
data-cy="k8sAppDetail-resourcePoolName"
>
<Link
to="kubernetes.resourcePools.resourcePool"
params={{ id: namespace }}
>
{namespace}
</Link>
{systemNamespace && <Badge type="info">system</Badge>}
</div>
</td>
</tr>
<tr>
<td>Application type</td>
<td data-cy="k8sAppDetail-appType">{application?.kind || '-'}</td>
</tr>
{application?.kind && (
<tr>
<td>Status</td>
{applicationIsKind<Pod>('Pod', application) && (
<td data-cy="k8sAppDetail-appType">
{application?.status?.phase}
</td>
)}
{!applicationIsKind<Pod>('Pod', application) && (
<td data-cy="k8sAppDetail-appType">
{appKindToDeploymentTypeMap[application.kind]}
<code className="ml-1">
{getRunningPods(application)}
</code> / <code>{getTotalPods(application)}</code>
</td>
)}
</tr>
)}
{(!!applicationRequests?.cpu || !!applicationRequests?.memoryBytes) && (
<tr>
<td>
Resource reservations
{!applicationIsKind<Pod>('Pod', application) && (
<div className="text-muted small">per instance</div>
)}
</td>
<td>
{!!applicationRequests?.cpu && (
<div data-cy="k8sAppDetail-cpuReservation">
CPU {applicationRequests.cpu}
</div>
)}
{!!applicationRequests?.memoryBytes && (
<div data-cy="k8sAppDetail-memoryReservation">
Memory{' '}
{bytesToReadableFormat(applicationRequests.memoryBytes)}
</div>
)}
</td>
</tr>
)}
<tr>
<td>Creation</td>
<td>
<div className="flex flex-wrap items-center gap-3">
{applicationOwner && (
<span
className="flex items-center gap-1"
data-cy="k8sAppDetail-owner"
>
<User />
{applicationOwner}
</span>
)}
<span
className="flex items-center gap-1"
data-cy="k8sAppDetail-creationDate"
>
<Clock />
{moment(application?.metadata?.creationTimestamp).format(
'YYYY-MM-DD HH:mm:ss'
)}
</span>
{(!externalApplication || systemNamespace) && (
<span
className="flex items-center gap-1"
data-cy="k8sAppDetail-creationMethod"
>
<Clock />
Deployed from {applicationDeployMethod}
</span>
)}
</div>
</td>
</tr>
<tr>
<td colSpan={2}>
<form className="form-horizontal">
<div className="form-group">
<div className="col-sm-12 vertical-center">
<Edit /> Note
<Button
size="xsmall"
type="button"
color="light"
data-cy="k8sAppDetail-expandNoteButton"
onClick={() => setIsNoteOpen(!isNoteOpen)}
>
{isNoteOpen ? 'Collapse' : 'Expand'}
{isNoteOpen ? <ChevronUp /> : <ChevronDown />}
</Button>
</div>
</div>
{isNoteOpen && (
<>
<div className="form-group">
<div className="col-sm-12">
<textarea
className="form-control resize-y"
name="application_note"
id="application_note"
value={applicationNoteFormValues}
onChange={(e) =>
setApplicationNoteFormValues(e.target.value)
}
rows={5}
placeholder="Enter a note about this application..."
/>
</div>
</div>
<Authorized authorizations="K8sApplicationDetailsW">
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
color="primary"
size="small"
className="!ml-0"
type="button"
onClick={() => patchApplicationNote()}
disabled={
// disable if there is no change to the note, or it's updating
applicationNoteFormValues ===
(applicationNote || '') ||
patchApplicationMutation.isLoading
}
data-cy="k8sAppDetail-saveNoteButton"
isLoading={patchApplicationMutation.isLoading}
loadingText={applicationNote ? 'Updating' : 'Saving'}
>
{applicationNote ? 'Update' : 'Save'} note
</LoadingButton>
</div>
</div>
</Authorized>
</>
)}
</form>
</td>
</tr>
</DetailsTable>
</div>
);
async function patchApplicationNote() {
const path = `/metadata/annotations/${appNoteAnnotation}`;
const value = applicationNoteFormValues;
if (application?.kind) {
try {
await patchApplicationMutation.mutateAsync({
appKind: application.kind,
path,
value,
});
notifySuccess('Success', 'Application successfully updated');
} catch (error) {
notifyError(
`Failed to ${applicationNote ? 'update' : 'save'} note`,
error as Error
);
}
}
}
}
function getApplicationDeployMethod(application?: Application) {
if (!application?.metadata?.labels?.[appDeployMethodLabel])
return 'application form';
if (application?.metadata?.labels?.[appDeployMethodLabel] === 'content') {
return 'manifest';
}
return application?.metadata?.labels?.[appDeployMethodLabel];
}

View File

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

View File

@ -0,0 +1,90 @@
import { useMutation, useQuery } from 'react-query';
import { queryClient, withError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
getApplicationsForCluster,
getApplication,
patchApplication,
} from './application.service';
import { AppKind } from './types';
const queryKeys = {
applicationsForCluster: (environmentId: EnvironmentId) => [
'environments',
environmentId,
'kubernetes',
'applications',
],
application: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
],
};
// useQuery to get a list of all applications from an array of namespaces
export function useApplicationsForCluster(
environemtId: EnvironmentId,
namespaces?: string[]
) {
return useQuery(
queryKeys.applicationsForCluster(environemtId),
() => namespaces && getApplicationsForCluster(environemtId, namespaces),
{
...withError('Unable to retrieve applications'),
enabled: !!namespaces,
}
);
}
// useQuery to get an application by environmentId, namespace and name
export function useApplication(
environmentId: EnvironmentId,
namespace: string,
name: string,
appKind?: AppKind
) {
return useQuery(
queryKeys.application(environmentId, namespace, name),
() => getApplication(environmentId, namespace, name, appKind),
{
...withError('Unable to retrieve application'),
}
);
}
// useQuery to patch an application by environmentId, namespace, name and patch payload
export function usePatchApplicationMutation(
environmentId: EnvironmentId,
namespace: string,
name: string
) {
return useMutation(
({
appKind,
path,
value,
}: {
appKind: AppKind;
path: string;
value: string;
}) =>
patchApplication(environmentId, namespace, appKind, name, path, value),
{
onSuccess: () => {
queryClient.invalidateQueries(
queryKeys.application(environmentId, namespace, name)
);
},
}
);
}

View File

@ -0,0 +1,268 @@
import {
DaemonSetList,
StatefulSetList,
DeploymentList,
Deployment,
DaemonSet,
StatefulSet,
} from 'kubernetes-types/apps/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/react/utils';
import { getPod, getPods, patchPod } from './pod.service';
import { getNakedPods } from './utils';
import { AppKind, Application, ApplicationList } from './types';
// This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets)
export async function getApplicationsForCluster(
environmentId: EnvironmentId,
namespaces: string[]
) {
try {
const applications = await Promise.all(
namespaces.map((namespace) =>
getApplicationsForNamespace(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, StatefulSets and naked pods (https://portainer.atlassian.net/browse/CE-2) in one namespace
async function getApplicationsForNamespace(
environmentId: EnvironmentId,
namespace: string
) {
try {
const [deployments, daemonSets, statefulSets, pods] = await Promise.all([
getApplicationsByKind<DeploymentList>(
environmentId,
namespace,
'Deployment'
),
getApplicationsByKind<DaemonSetList>(
environmentId,
namespace,
'DaemonSet'
),
getApplicationsByKind<StatefulSetList>(
environmentId,
namespace,
'StatefulSet'
),
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}`
);
}
}
// if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name
export async function getApplication(
environmentId: EnvironmentId,
namespace: string,
name: string,
appKind?: AppKind
) {
try {
// if resourceType is known, get the application by type and name
if (appKind) {
switch (appKind) {
case 'Deployment':
case 'DaemonSet':
case 'StatefulSet':
return await getApplicationByKind(
environmentId,
namespace,
appKind,
name
);
case 'Pod':
return await getPod(environmentId, namespace, name);
default:
throw new Error('Unknown resource type');
}
}
// if resourceType is not known, get the application by name and return the first one that is fulfilled
const [deployment, daemonSet, statefulSet, pod] = await Promise.allSettled([
getApplicationByKind<Deployment>(
environmentId,
namespace,
'Deployment',
name
),
getApplicationByKind<DaemonSet>(
environmentId,
namespace,
'DaemonSet',
name
),
getApplicationByKind<StatefulSet>(
environmentId,
namespace,
'StatefulSet',
name
),
getPod(environmentId, namespace, name),
]);
if (isFulfilled(deployment)) {
return deployment.value;
}
if (isFulfilled(daemonSet)) {
return daemonSet.value;
}
if (isFulfilled(statefulSet)) {
return statefulSet.value;
}
if (isFulfilled(pod)) {
return pod.value;
}
throw new Error('Unable to retrieve application');
} catch (e) {
throw parseAxiosError(
e as Error,
`Unable to retrieve application ${name} in namespace ${namespace}`
);
}
}
export async function patchApplication(
environmentId: EnvironmentId,
namespace: string,
appKind: AppKind,
name: string,
path: string,
value: string
) {
try {
switch (appKind) {
case 'Deployment':
return await patchApplicationByKind<Deployment>(
environmentId,
namespace,
appKind,
name,
path,
value
);
case 'DaemonSet':
return await patchApplicationByKind<DaemonSet>(
environmentId,
namespace,
appKind,
name,
path,
value
);
case 'StatefulSet':
return await patchApplicationByKind<StatefulSet>(
environmentId,
namespace,
appKind,
name,
path,
value
);
case 'Pod':
return await patchPod(environmentId, namespace, name, path, value);
default:
throw new Error(`Unknown application kind ${appKind}`);
}
} catch (e) {
throw parseAxiosError(
e as Error,
`Unable to patch application ${name} in namespace ${namespace}`
);
}
}
async function patchApplicationByKind<T extends Application>(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
name: string,
path: string,
value: string
) {
const payload = [
{
op: 'replace',
path,
value,
},
];
try {
const res = await axios.patch<T>(
buildUrl(environmentId, namespace, `${appKind}s`, name),
payload,
{
headers: {
'Content-Type': 'application/json-patch+json',
},
}
);
return res;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to patch application');
}
}
async function getApplicationByKind<T extends Application>(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
name: string
) {
try {
const { data } = await axios.get<T>(
buildUrl(environmentId, namespace, `${appKind}s`, name)
);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve application');
}
}
async function getApplicationsByKind<T extends ApplicationList>(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet'
) {
try {
const { data } = await axios.get<T>(
buildUrl(environmentId, namespace, `${appKind}s`)
);
return data.items as T['items'];
} catch (e) {
throw parseAxiosError(e as Error, `Unable to retrieve ${appKind}s`);
}
}
function buildUrl(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets',
name?: string
) {
let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`;
if (name) {
baseUrl += `/${name}`;
}
return baseUrl;
}

View File

@ -0,0 +1,17 @@
import { AppKind, DeploymentType } from './types';
// Portainer specific labels
export const appStackNameLabel = 'io.portainer.kubernetes.application.stack';
export const appOwnerLabel = 'io.portainer.kubernetes.application.owner';
export const appNoteAnnotation = 'io.portainer.kubernetes.application.note';
export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind';
export const appKindToDeploymentTypeMap: Record<
AppKind,
DeploymentType | null
> = {
Deployment: 'Replicated',
StatefulSet: 'Replicated',
DaemonSet: 'Global',
Pod: null,
};

View File

@ -0,0 +1,66 @@
import { Pod, PodList } from 'kubernetes-types/core/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
export async function getPods(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<PodList>(
buildUrl(environmentId, namespace)
);
return data.items;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve pods');
}
}
export async function getPod(
environmentId: EnvironmentId,
namespace: string,
name: string
) {
try {
const { data } = await axios.get<Pod>(
buildUrl(environmentId, namespace, name)
);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve pod');
}
}
export async function patchPod(
environmentId: EnvironmentId,
namespace: string,
name: string,
path: string,
value: string
) {
const payload = [
{
op: 'replace',
path,
value,
},
];
try {
return await axios.put<Pod>(
buildUrl(environmentId, namespace, name),
payload
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update pod');
}
}
export function buildUrl(
environmentId: EnvironmentId,
namespace: string,
name?: string
) {
let baseUrl = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`;
if (name) {
baseUrl += `/${name}`;
}
return baseUrl;
}

View File

@ -1,21 +0,0 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { getApplicationsListForCluster } from './service';
// useQuery to get a list of all applications from an array of namespaces
export function useApplicationsForCluster(
environemtId: EnvironmentId,
namespaces?: string[]
) {
return useQuery(
['environments', environemtId, 'kubernetes', 'applications'],
() => namespaces && getApplicationsListForCluster(environemtId, namespaces),
{
...withError('Unable to retrieve applications'),
enabled: !!namespaces,
}
);
}

View File

@ -1,141 +0,0 @@
import { Pod, PodList } from 'kubernetes-types/core/v1';
import {
Deployment,
DeploymentList,
DaemonSet,
DaemonSetList,
StatefulSet,
StatefulSetList,
} from 'kubernetes-types/apps/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
export async function getApplicationsListForCluster(
environmentId: EnvironmentId,
namespaces: string[]
) {
try {
const applications = await Promise.all(
namespaces.map((namespace) =>
getApplicationsListForNamespace(environmentId, namespace)
)
);
return applications.flat();
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve applications for cluster'
);
}
}
// get a list of all Deployments, DaemonSets and StatefulSets in one namespace
export async function getApplicationsListForNamespace(
environmentId: EnvironmentId,
namespace: string
) {
try {
const [deployments, daemonSets, statefulSets, pods] = await Promise.all([
getDeployments(environmentId, namespace),
getDaemonSets(environmentId, namespace),
getStatefulSets(environmentId, namespace),
getPods(environmentId, namespace),
]);
// find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset)
const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets);
return [...deployments, ...daemonSets, ...statefulSets, ...nakedPods];
} catch (e) {
throw parseAxiosError(
e as Error,
`Unable to retrieve applications in namespace ${namespace}`
);
}
}
async function getDeployments(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<DeploymentList>(
buildUrl(environmentId, namespace, 'deployments')
);
return data.items;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve deployments');
}
}
async function getDaemonSets(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<DaemonSetList>(
buildUrl(environmentId, namespace, 'daemonsets')
);
return data.items;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve daemonsets');
}
}
async function getStatefulSets(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data } = await axios.get<StatefulSetList>(
buildUrl(environmentId, namespace, 'statefulsets')
);
return data.items;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve statefulsets');
}
}
async function getPods(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<PodList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`
);
return data.items;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve pods');
}
}
function buildUrl(
environmentId: EnvironmentId,
namespace: string,
appResource: 'deployments' | 'daemonsets' | 'statefulsets'
) {
return `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appResource}`;
}
function getNakedPods(
pods: Pod[],
deployments: Deployment[],
daemonSets: DaemonSet[],
statefulSets: StatefulSet[]
) {
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
const appLabels = [
...deployments.map((deployment) => deployment.spec?.selector.matchLabels),
...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels),
...statefulSets.map(
(statefulSet) => statefulSet.spec?.selector.matchLabels
),
];
const nakedPods = pods.filter((pod) => {
const podLabels = pod.metadata?.labels;
// if the pod has no labels, it is naked
if (!podLabels) return true;
// if the pod has labels, but no app labels, it is naked
return !appLabels.some((appLabel) => {
if (!appLabel) return false;
return Object.entries(appLabel).every(
([key, value]) => podLabels[key] === value
);
});
});
return nakedPods;
}

View File

@ -0,0 +1,21 @@
import {
DaemonSet,
DaemonSetList,
Deployment,
DeploymentList,
StatefulSet,
StatefulSetList,
} from 'kubernetes-types/apps/v1';
import { Pod, PodList } from 'kubernetes-types/core/v1';
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
export type ApplicationList =
| DeploymentList
| DaemonSetList
| StatefulSetList
| PodList;
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
export type DeploymentType = 'Replicated' | 'Global';

View File

@ -0,0 +1,167 @@
import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1';
import { Pod } from 'kubernetes-types/core/v1';
import filesizeParser from 'filesize-parser';
import { Application } from './types';
import { appOwnerLabel } from './constants';
export 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;
}
// type guard to check if an application is a deployment, daemonset statefulset or pod
export function applicationIsKind<T extends Application>(
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod',
application?: Application
): application is T {
return application?.kind === appKind;
}
// the application is external if it has no owner label
export function isExternalApplication(application: Application) {
return !application.metadata?.labels?.[appOwnerLabel];
}
function getDeploymentRunningPods(deployment: Deployment): number {
const availableReplicas = deployment.status?.availableReplicas ?? 0;
const totalReplicas = deployment.status?.replicas ?? 0;
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
return availableReplicas || totalReplicas - unavailableReplicas;
}
function getDaemonSetRunningPods(daemonSet: DaemonSet): number {
const numberAvailable = daemonSet.status?.numberAvailable ?? 0;
const desiredNumberScheduled = daemonSet.status?.desiredNumberScheduled ?? 0;
const numberUnavailable = daemonSet.status?.numberUnavailable ?? 0;
return numberAvailable || desiredNumberScheduled - numberUnavailable;
}
function getStatefulSetRunningPods(statefulSet: StatefulSet): number {
return statefulSet.status?.readyReplicas ?? 0;
}
export function getRunningPods(
application: Deployment | DaemonSet | StatefulSet
): number {
switch (application.kind) {
case 'Deployment':
return getDeploymentRunningPods(application);
case 'DaemonSet':
return getDaemonSetRunningPods(application);
case 'StatefulSet':
return getStatefulSetRunningPods(application);
default:
throw new Error('Unknown application type');
}
}
export function getTotalPods(
application: Deployment | DaemonSet | StatefulSet
): number {
switch (application.kind) {
case 'Deployment':
return application.status?.replicas ?? 0;
case 'DaemonSet':
return application.status?.desiredNumberScheduled ?? 0;
case 'StatefulSet':
return application.status?.replicas ?? 0;
default:
throw new Error('Unknown application type');
}
}
function parseCpu(cpu: string) {
let res = parseInt(cpu, 10);
if (cpu.endsWith('m')) {
res /= 1000;
} else if (cpu.endsWith('n')) {
res /= 1000000000;
}
return res;
}
// bytesToReadableFormat converts bytes to a human readable string (e.g. '1.5 GB'), assuming base 10
// there's some discussion about whether base 2 or base 10 should be used for memory units
// https://www.quora.com/Is-1-GB-equal-to-1024-MB-or-1000-MB
export function bytesToReadableFormat(memoryBytes: number) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let memoryValue = memoryBytes;
while (memoryValue > 1000 && unitIndex < units.length) {
memoryValue /= 1000;
unitIndex++;
}
return `${memoryValue.toFixed(1)} ${units[unitIndex]}`;
}
// getResourceRequests returns the total cpu and memory requests for all containers in an application
export function getResourceRequests(application: Application) {
const appContainers = applicationIsKind<Pod>('Pod', application)
? application.spec?.containers
: application.spec?.template.spec?.containers;
if (!appContainers) return null;
const requests = appContainers.reduce(
(acc, container) => {
const cpu = container.resources?.requests?.cpu;
const memory = container.resources?.requests?.memory;
if (cpu) acc.cpu += parseCpu(cpu);
if (memory) acc.memoryBytes += filesizeParser(memory, { base: 10 });
return acc;
},
{ cpu: 0, memoryBytes: 0 }
);
return requests;
}
// getResourceLimits returns the total cpu and memory limits for all containers in an application
export function getResourceLimits(application: Application) {
const appContainers = applicationIsKind<Pod>('Pod', application)
? application.spec?.containers
: application.spec?.template.spec?.containers;
if (!appContainers) return null;
const limits = appContainers.reduce(
(acc, container) => {
const cpu = container.resources?.limits?.cpu;
const memory = container.resources?.limits?.memory;
if (cpu) acc.cpu += parseCpu(cpu);
if (memory) acc.memory += filesizeParser(memory, { base: 10 });
return acc;
},
{ cpu: 0, memory: 0 }
);
return limits;
}

View File

@ -7,6 +7,7 @@ import {
withInvalidate,
} from '@/react-tools/react-query';
import { getServices } from '@/react/kubernetes/networks/services/service';
import { isFulfilled } from '@/react/utils';
import {
getIngresses,
@ -193,9 +194,3 @@ export function useIngressControllers(
}
);
}
function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}

View File

@ -0,0 +1,10 @@
export const systemNamespaces = [
'kube-system',
'kube-public',
'kube-node-lease',
'portainer',
];
export function isSystemNamespace(namespace: string) {
return systemNamespaces.includes(namespace || '');
}

5
app/react/utils.ts Normal file
View File

@ -0,0 +1,5 @@
export function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}

View File

@ -143,6 +143,7 @@
"@testing-library/user-event": "^13.5.0",
"@types/angular": "^1.8.3",
"@types/file-saver": "^2.0.4",
"@types/filesize-parser": "^1.5.1",
"@types/jest": "^27.0.3",
"@types/jquery": "^3.5.10",
"@types/mustache": "^4.1.2",

View File

@ -4725,6 +4725,11 @@
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.4.tgz#aaf9b96296150d737b2fefa535ced05ed8013d84"
integrity sha512-sPZYQEIF/SOnLAvaz9lTuydniP+afBMtElRTdYkeV1QtEgvtJ7qolCPjly6O32QI8CbEmP5O/fztMXEDWfEcrg==
"@types/filesize-parser@^1.5.1":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@types/filesize-parser/-/filesize-parser-1.5.1.tgz#d1218f48ad160d7b089a555f3984439254196bd7"
integrity sha512-kU4V/I/EUARPF3hxTRrZ1ad2P0/9sby1JoRbNcpvMIPqhkVlBqcNc+HutiLl6cS7iCId54Xd6X+BEp5vUgSY6Q==
"@types/fined@*":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@types/fined/-/fined-1.1.3.tgz#83f03e8f0a8d3673dfcafb18fce3571f6250e1bc"