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
parent
745bbb7d79
commit
98e6393274
|
@ -151,7 +151,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
|
|
||||||
const application = {
|
const application = {
|
||||||
name: 'kubernetes.applications.application',
|
name: 'kubernetes.applications.application',
|
||||||
url: '/:namespace/:name',
|
url: '/:namespace/:name?resource-type',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'kubernetesApplicationView',
|
component: 'kubernetesApplicationView',
|
||||||
|
|
|
@ -276,7 +276,7 @@
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
ng-if="!item.KubernetesApplications"
|
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()"
|
ng-click="$event.stopPropagation()"
|
||||||
class="hyperlink"
|
class="hyperlink"
|
||||||
>{{ item.Name }}
|
>{{ item.Name }}
|
||||||
|
|
|
@ -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.expandAll = function () {
|
||||||
this.state.expandAll = !this.state.expandAll;
|
this.state.expandAll = !this.state.expandAll;
|
||||||
this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll));
|
this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll));
|
||||||
|
|
|
@ -8,6 +8,10 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce
|
||||||
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
||||||
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
||||||
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
|
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
|
export const componentsModule = angular
|
||||||
.module('portainer.kubernetes.react.components', [])
|
.module('portainer.kubernetes.react.components', [])
|
||||||
|
@ -82,4 +86,11 @@ export const componentsModule = angular
|
||||||
'onChange',
|
'onChange',
|
||||||
'supportGlobalDeployment',
|
'supportGlobalDeployment',
|
||||||
])
|
])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'applicationSummaryWidget',
|
||||||
|
r2a(
|
||||||
|
withUIRouter(withReactQuery(withUserProvider(ApplicationSummaryWidget))),
|
||||||
|
[]
|
||||||
|
)
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -25,133 +25,7 @@
|
||||||
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
||||||
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
|
<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>
|
<uib-tab-heading> <pr-icon icon="'svg-laptopcode'" class-name="'mr-1'"></pr-icon> Application </uib-tab-heading>
|
||||||
<div style="padding: 20px">
|
<application-summary-widget></application-summary-widget>
|
||||||
<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>
|
|
||||||
</uib-tab>
|
</uib-tab>
|
||||||
|
|
||||||
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
|
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { DashboardItem } from '@@/DashboardItem/DashboardItem';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
import { useNamespaces } from '../namespaces/queries';
|
import { useNamespaces } from '../namespaces/queries';
|
||||||
import { useApplicationsForCluster } from '../applications/queries';
|
import { useApplicationsForCluster } from '../applications/application.queries';
|
||||||
import { useConfigurationsForCluster } from '../configs/queries';
|
import { useConfigurationsForCluster } from '../configs/queries';
|
||||||
import { usePVCsForCluster } from '../volumes/queries';
|
import { usePVCsForCluster } from '../volumes/queries';
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { compact } from 'lodash';
|
||||||
import { withError } from '@/react-tools/react-query';
|
import { withError } from '@/react-tools/react-query';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { isFulfilled } from '@/react/utils';
|
||||||
|
|
||||||
import { getNamespaces } from '../namespaces/service';
|
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) {
|
export function useMutationDeleteServices(environmentId: EnvironmentId) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation(deleteServices, {
|
return useMutation(deleteServices, {
|
||||||
|
|
|
@ -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];
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
|
|
@ -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)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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';
|
|
@ -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;
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import {
|
||||||
withInvalidate,
|
withInvalidate,
|
||||||
} from '@/react-tools/react-query';
|
} from '@/react-tools/react-query';
|
||||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||||
|
import { isFulfilled } from '@/react/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getIngresses,
|
getIngresses,
|
||||||
|
@ -193,9 +194,3 @@ export function useIngressControllers(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFulfilled<T>(
|
|
||||||
input: PromiseSettledResult<T>
|
|
||||||
): input is PromiseFulfilledResult<T> {
|
|
||||||
return input.status === 'fulfilled';
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const systemNamespaces = [
|
||||||
|
'kube-system',
|
||||||
|
'kube-public',
|
||||||
|
'kube-node-lease',
|
||||||
|
'portainer',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSystemNamespace(namespace: string) {
|
||||||
|
return systemNamespaces.includes(namespace || '');
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function isFulfilled<T>(
|
||||||
|
input: PromiseSettledResult<T>
|
||||||
|
): input is PromiseFulfilledResult<T> {
|
||||||
|
return input.status === 'fulfilled';
|
||||||
|
}
|
|
@ -143,6 +143,7 @@
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@types/angular": "^1.8.3",
|
"@types/angular": "^1.8.3",
|
||||||
"@types/file-saver": "^2.0.4",
|
"@types/file-saver": "^2.0.4",
|
||||||
|
"@types/filesize-parser": "^1.5.1",
|
||||||
"@types/jest": "^27.0.3",
|
"@types/jest": "^27.0.3",
|
||||||
"@types/jquery": "^3.5.10",
|
"@types/jquery": "^3.5.10",
|
||||||
"@types/mustache": "^4.1.2",
|
"@types/mustache": "^4.1.2",
|
||||||
|
|
|
@ -4725,6 +4725,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.4.tgz#aaf9b96296150d737b2fefa535ced05ed8013d84"
|
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.4.tgz#aaf9b96296150d737b2fefa535ced05ed8013d84"
|
||||||
integrity sha512-sPZYQEIF/SOnLAvaz9lTuydniP+afBMtElRTdYkeV1QtEgvtJ7qolCPjly6O32QI8CbEmP5O/fztMXEDWfEcrg==
|
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@*":
|
"@types/fined@*":
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/fined/-/fined-1.1.3.tgz#83f03e8f0a8d3673dfcafb18fce3571f6250e1bc"
|
resolved "https://registry.yarnpkg.com/@types/fined/-/fined-1.1.3.tgz#83f03e8f0a8d3673dfcafb18fce3571f6250e1bc"
|
||||||
|
|
Loading…
Reference in New Issue