mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(app): migrate app summary section [EE-6239] (#10910)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
This commit is contained in:
parent
7a4314032a
commit
abf517de28
61 changed files with 1461 additions and 661 deletions
|
@ -0,0 +1,73 @@
|
|||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ApplicationFormValues } from '../../types';
|
||||
|
||||
import { getAppResourceSummaries, getArticle } from './utils';
|
||||
import { Summary } from './types';
|
||||
|
||||
type Props = {
|
||||
formValues: ApplicationFormValues;
|
||||
oldFormValues: ApplicationFormValues;
|
||||
};
|
||||
|
||||
export function ApplicationSummarySection({
|
||||
formValues,
|
||||
oldFormValues,
|
||||
}: Props) {
|
||||
// extract cpu and memory requests & limits for pod
|
||||
const limits = {
|
||||
cpu: formValues.CpuLimit,
|
||||
memory: formValues.MemoryLimit,
|
||||
};
|
||||
const appResourceSummaries = getAppResourceSummaries(
|
||||
formValues,
|
||||
oldFormValues
|
||||
);
|
||||
|
||||
if (!appResourceSummaries || appResourceSummaries?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Summary" isFoldable defaultFolded={false}>
|
||||
<TextTip color="blue">
|
||||
Portainer will execute the following Kubernetes actions.
|
||||
</TextTip>
|
||||
<ul className="w-full small text-muted ml-5">
|
||||
{appResourceSummaries.map((summary) => (
|
||||
<SummaryItem key={JSON.stringify(summary)} summary={summary} />
|
||||
))}
|
||||
{!!limits.memory && (
|
||||
<li>
|
||||
Set the memory resources limits and requests to{' '}
|
||||
<code>{limits.memory}M</code>
|
||||
</li>
|
||||
)}
|
||||
{!!limits.cpu && (
|
||||
<li>
|
||||
Set the CPU resources limits and requests to{' '}
|
||||
<code>{limits.cpu}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ summary }: { summary: Summary }) {
|
||||
return (
|
||||
<li>
|
||||
{`${summary.action} ${getArticle(summary.kind, summary.action)} `}
|
||||
<span className="bold">{summary.kind}</span>
|
||||
{' named '}
|
||||
<code>{summary.name}</code>
|
||||
{!!summary.type && (
|
||||
<span>
|
||||
{' of type '}
|
||||
<code>{summary.type}</code>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ApplicationSummarySection } from './ApplicationSummarySection';
|
|
@ -0,0 +1,21 @@
|
|||
import { AppKind } from '../../types';
|
||||
|
||||
export type KubernetesResourceAction = 'Create' | 'Update' | 'Delete';
|
||||
|
||||
export type KubernetesResourceType =
|
||||
| AppKind
|
||||
| 'Namespace'
|
||||
| 'ResourceQuota'
|
||||
| 'ConfigMap'
|
||||
| 'Secret'
|
||||
| 'PersistentVolumeClaim'
|
||||
| 'Service'
|
||||
| 'Ingress'
|
||||
| 'HorizontalPodAutoscaler';
|
||||
|
||||
export type Summary = {
|
||||
action: KubernetesResourceAction;
|
||||
kind: KubernetesResourceType;
|
||||
name: string;
|
||||
type?: string;
|
||||
};
|
|
@ -0,0 +1,517 @@
|
|||
import { ApplicationFormValues } from '../../types';
|
||||
|
||||
import { Summary } from './types';
|
||||
import { getAppResourceSummaries } from './utils';
|
||||
|
||||
const complicatedStatefulSet: ApplicationFormValues = {
|
||||
ApplicationType: 'StatefulSet',
|
||||
ResourcePool: {
|
||||
Namespace: {
|
||||
Id: '9ef75267-3cf4-46f6-879a-5baeceb5c477',
|
||||
Name: 'default',
|
||||
CreationDate: '2023-08-30T18:55:34Z',
|
||||
Status: 'Active',
|
||||
Yaml: '',
|
||||
IsSystem: false,
|
||||
Annotations: [],
|
||||
},
|
||||
Ingresses: [],
|
||||
Yaml: '',
|
||||
$$hashKey: 'object:702',
|
||||
},
|
||||
Name: 'my-app',
|
||||
StackName: '',
|
||||
ApplicationOwner: '',
|
||||
ImageModel: {
|
||||
UseRegistry: true,
|
||||
Registry: {
|
||||
Id: 0,
|
||||
Type: 0,
|
||||
Name: 'Docker Hub (anonymous)',
|
||||
URL: 'docker.io',
|
||||
},
|
||||
Image: 'caddy',
|
||||
},
|
||||
Note: '',
|
||||
MemoryLimit: 512,
|
||||
CpuLimit: 0.5,
|
||||
DeploymentType: 'Replicated',
|
||||
ReplicaCount: 1,
|
||||
AutoScaler: {
|
||||
isUsed: true,
|
||||
minReplicas: 1,
|
||||
maxReplicas: 3,
|
||||
targetCpuUtilizationPercentage: 50,
|
||||
},
|
||||
Containers: [],
|
||||
Services: [
|
||||
{
|
||||
Headless: false,
|
||||
Namespace: '',
|
||||
Name: 'my-app',
|
||||
StackName: '',
|
||||
Ports: [
|
||||
{
|
||||
port: 80,
|
||||
targetPort: 80,
|
||||
name: '',
|
||||
protocol: 'TCP',
|
||||
serviceName: 'my-app',
|
||||
ingressPaths: [
|
||||
{
|
||||
Host: '127.0.0.1.nip.io',
|
||||
IngressName: 'default-ingress-3',
|
||||
Path: '/test',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Type: 'ClusterIP',
|
||||
ClusterIP: '',
|
||||
ApplicationName: '',
|
||||
ApplicationOwner: '',
|
||||
Note: '',
|
||||
Ingress: false,
|
||||
},
|
||||
{
|
||||
Headless: false,
|
||||
Namespace: '',
|
||||
Name: 'my-app-2',
|
||||
StackName: '',
|
||||
Ports: [
|
||||
{
|
||||
port: 80,
|
||||
targetPort: 80,
|
||||
name: '',
|
||||
protocol: 'TCP',
|
||||
nodePort: 30080,
|
||||
serviceName: 'my-app-2',
|
||||
},
|
||||
],
|
||||
Type: 'NodePort',
|
||||
ClusterIP: '',
|
||||
ApplicationName: '',
|
||||
ApplicationOwner: '',
|
||||
Note: '',
|
||||
Ingress: false,
|
||||
},
|
||||
{
|
||||
Headless: false,
|
||||
Namespace: '',
|
||||
Name: 'my-app-3',
|
||||
StackName: '',
|
||||
Ports: [
|
||||
{
|
||||
port: 80,
|
||||
targetPort: 80,
|
||||
name: '',
|
||||
protocol: 'TCP',
|
||||
serviceName: 'my-app-3',
|
||||
},
|
||||
],
|
||||
Type: 'LoadBalancer',
|
||||
ClusterIP: '',
|
||||
ApplicationName: '',
|
||||
ApplicationOwner: '',
|
||||
Note: '',
|
||||
Ingress: false,
|
||||
},
|
||||
],
|
||||
EnvironmentVariables: [],
|
||||
DataAccessPolicy: 'Isolated',
|
||||
PersistedFolders: [
|
||||
{
|
||||
persistentVolumeClaimName: 'my-app-6be07c40-de3a-4775-a29b-19a60890052e',
|
||||
containerPath: 'test',
|
||||
size: '1',
|
||||
sizeUnit: 'GB',
|
||||
storageClass: {
|
||||
Name: 'local-path',
|
||||
AccessModes: ['RWO', 'RWX'],
|
||||
Provisioner: 'rancher.io/local-path',
|
||||
AllowVolumeExpansion: true,
|
||||
},
|
||||
useNewVolume: true,
|
||||
needsDeletion: false,
|
||||
},
|
||||
],
|
||||
ConfigMaps: [],
|
||||
Secrets: [],
|
||||
PlacementType: 'preferred',
|
||||
Placements: [],
|
||||
Annotations: [],
|
||||
};
|
||||
|
||||
const complicatedStatefulSetNoServices: ApplicationFormValues = {
|
||||
ApplicationType: 'StatefulSet',
|
||||
ResourcePool: {
|
||||
Namespace: {
|
||||
Id: '9ef75267-3cf4-46f6-879a-5baeceb5c477',
|
||||
Name: 'default',
|
||||
CreationDate: '2023-08-30T18:55:34Z',
|
||||
Status: 'Active',
|
||||
Yaml: '',
|
||||
IsSystem: false,
|
||||
Annotations: [],
|
||||
},
|
||||
Ingresses: [],
|
||||
Yaml: '',
|
||||
$$hashKey: 'object:129',
|
||||
},
|
||||
Name: 'my-app',
|
||||
StackName: 'my-app',
|
||||
ApplicationOwner: 'admin',
|
||||
ImageModel: {
|
||||
UseRegistry: true,
|
||||
Registry: {
|
||||
Id: 0,
|
||||
Type: 0,
|
||||
Name: 'Docker Hub (anonymous)',
|
||||
URL: 'docker.io',
|
||||
},
|
||||
Image: 'caddy:latest',
|
||||
},
|
||||
Note: '',
|
||||
MemoryLimit: 512,
|
||||
CpuLimit: 0.5,
|
||||
DeploymentType: 'Replicated',
|
||||
ReplicaCount: 1,
|
||||
AutoScaler: {
|
||||
minReplicas: 1,
|
||||
maxReplicas: 3,
|
||||
targetCpuUtilizationPercentage: 50,
|
||||
isUsed: true,
|
||||
},
|
||||
Containers: [
|
||||
{
|
||||
Type: 2,
|
||||
PodName: 'my-app-0',
|
||||
Name: 'my-app',
|
||||
Image: 'caddy:latest',
|
||||
ImagePullPolicy: 'Always',
|
||||
Status: 'Terminated',
|
||||
Limits: {
|
||||
cpu: '500m',
|
||||
memory: '512M',
|
||||
},
|
||||
Requests: {
|
||||
cpu: '500m',
|
||||
memory: '512M',
|
||||
},
|
||||
VolumeMounts: [
|
||||
{
|
||||
name: 'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
|
||||
mountPath: '/test',
|
||||
},
|
||||
{
|
||||
name: 'kube-api-access-n4vht',
|
||||
readOnly: true,
|
||||
mountPath: '/var/run/secrets/kubernetes.io/serviceaccount',
|
||||
},
|
||||
],
|
||||
ConfigurationVolumes: [],
|
||||
PersistedFolders: [
|
||||
{
|
||||
MountPath: '/test',
|
||||
persistentVolumeClaimName:
|
||||
'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
|
||||
HostPath: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Services: [],
|
||||
EnvironmentVariables: [],
|
||||
DataAccessPolicy: 'Isolated',
|
||||
PersistedFolders: [
|
||||
{
|
||||
persistentVolumeClaimName:
|
||||
'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
|
||||
needsDeletion: false,
|
||||
containerPath: '/test',
|
||||
size: '1',
|
||||
sizeUnit: 'GB',
|
||||
storageClass: {
|
||||
Name: 'local-path',
|
||||
AccessModes: ['RWO', 'RWX'],
|
||||
Provisioner: 'rancher.io/local-path',
|
||||
AllowVolumeExpansion: true,
|
||||
},
|
||||
useNewVolume: true,
|
||||
},
|
||||
],
|
||||
ConfigMaps: [],
|
||||
Secrets: [],
|
||||
PlacementType: 'preferred',
|
||||
Placements: [],
|
||||
Annotations: [],
|
||||
};
|
||||
|
||||
const createComplicatedStatefulSetSummaries: Array<Summary> = [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'StatefulSet',
|
||||
name: 'my-app',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: 'my-app',
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: 'my-app-2',
|
||||
type: 'NodePort',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: 'my-app-3',
|
||||
type: 'LoadBalancer',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: 'headless-my-app',
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
{
|
||||
action: 'Update',
|
||||
kind: 'Ingress',
|
||||
name: 'default-ingress-3',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
name: 'my-app',
|
||||
},
|
||||
];
|
||||
|
||||
const simpleDaemonset: ApplicationFormValues = {
|
||||
ApplicationType: 'DaemonSet',
|
||||
ResourcePool: {
|
||||
Namespace: {
|
||||
Id: '49acd824-0ee4-46d1-b1e2-3d36a64ce7e4',
|
||||
Name: 'default',
|
||||
CreationDate: '2023-12-19T06:40:12Z',
|
||||
Status: 'Active',
|
||||
Yaml: '',
|
||||
IsSystem: false,
|
||||
Annotations: [],
|
||||
},
|
||||
Ingresses: [],
|
||||
Yaml: '',
|
||||
$$hashKey: 'object:418',
|
||||
},
|
||||
Name: 'my-app',
|
||||
StackName: '',
|
||||
ApplicationOwner: '',
|
||||
ImageModel: {
|
||||
UseRegistry: true,
|
||||
Registry: {
|
||||
Id: 0,
|
||||
Type: 0,
|
||||
Name: 'Docker Hub (anonymous)',
|
||||
URL: 'docker.io',
|
||||
},
|
||||
Image: 'caddy',
|
||||
},
|
||||
Note: '',
|
||||
MemoryLimit: 0,
|
||||
CpuLimit: 0,
|
||||
DeploymentType: 'Global',
|
||||
ReplicaCount: 1,
|
||||
Containers: [],
|
||||
DataAccessPolicy: 'Shared',
|
||||
PersistedFolders: [
|
||||
{
|
||||
persistentVolumeClaimName: 'my-app-7c114420-a5d0-491c-8bd6-ec70c3d380be',
|
||||
containerPath: '/test',
|
||||
size: '1',
|
||||
sizeUnit: 'GB',
|
||||
storageClass: {
|
||||
Name: 'oci',
|
||||
AccessModes: ['RWO', 'RWX'],
|
||||
Provisioner: 'oracle.com/oci',
|
||||
AllowVolumeExpansion: true,
|
||||
},
|
||||
useNewVolume: true,
|
||||
needsDeletion: false,
|
||||
},
|
||||
],
|
||||
PlacementType: 'preferred',
|
||||
};
|
||||
|
||||
const createSimpleDaemonsetSummaries: Array<Summary> = [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'DaemonSet',
|
||||
name: 'my-app',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
name: 'my-app-7c114420-a5d0-491c-8bd6-ec70c3d380be',
|
||||
},
|
||||
];
|
||||
|
||||
const simpleDeployment: ApplicationFormValues = {
|
||||
ApplicationType: 'Deployment',
|
||||
ResourcePool: {
|
||||
Namespace: {
|
||||
Id: '49acd824-0ee4-46d1-b1e2-3d36a64ce7e4',
|
||||
Name: 'default',
|
||||
CreationDate: '2023-12-19T06:40:12Z',
|
||||
Status: 'Active',
|
||||
Yaml: '',
|
||||
IsSystem: false,
|
||||
Annotations: [],
|
||||
},
|
||||
Ingresses: [],
|
||||
Yaml: '',
|
||||
$$hashKey: 'object:582',
|
||||
},
|
||||
Name: 'my-app',
|
||||
StackName: '',
|
||||
ApplicationOwner: '',
|
||||
ImageModel: {
|
||||
UseRegistry: true,
|
||||
Registry: {
|
||||
Id: 0,
|
||||
Type: 0,
|
||||
Name: 'Docker Hub (anonymous)',
|
||||
URL: 'docker.io',
|
||||
},
|
||||
Image: 'caddy',
|
||||
},
|
||||
Note: '',
|
||||
MemoryLimit: 512,
|
||||
CpuLimit: 0.5,
|
||||
DeploymentType: 'Replicated',
|
||||
ReplicaCount: 1,
|
||||
Containers: [],
|
||||
DataAccessPolicy: 'Isolated',
|
||||
PlacementType: 'preferred',
|
||||
};
|
||||
|
||||
const createSimpleDeploymentSummaries: Array<Summary> = [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Deployment',
|
||||
name: 'my-app',
|
||||
},
|
||||
];
|
||||
|
||||
describe('getCreateAppSummaries', () => {
|
||||
const tests: {
|
||||
oldFormValues?: ApplicationFormValues;
|
||||
newFormValues: ApplicationFormValues;
|
||||
expected: Array<Summary>;
|
||||
title: string;
|
||||
}[] = [
|
||||
{
|
||||
oldFormValues: undefined,
|
||||
newFormValues: complicatedStatefulSet,
|
||||
expected: createComplicatedStatefulSetSummaries,
|
||||
title: 'should return create summaries for a complicated statefulset',
|
||||
},
|
||||
{
|
||||
oldFormValues: undefined,
|
||||
newFormValues: simpleDaemonset,
|
||||
expected: createSimpleDaemonsetSummaries,
|
||||
title: 'should return create summaries for a simple daemonset',
|
||||
},
|
||||
{
|
||||
oldFormValues: undefined,
|
||||
newFormValues: simpleDeployment,
|
||||
expected: createSimpleDeploymentSummaries,
|
||||
title: 'should return create summaries for a simple deployment',
|
||||
},
|
||||
];
|
||||
tests.forEach((test) => {
|
||||
// eslint-disable-next-line jest/valid-title
|
||||
it(test.title, () => {
|
||||
expect(
|
||||
getAppResourceSummaries(test.newFormValues, test.oldFormValues)
|
||||
).toEqual(test.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const updateComplicatedStatefulSetSummaries: Array<Summary> = [
|
||||
{
|
||||
action: 'Update',
|
||||
kind: 'StatefulSet',
|
||||
name: 'my-app',
|
||||
},
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: 'Service',
|
||||
name: 'my-app',
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: 'Service',
|
||||
name: 'my-app-2',
|
||||
type: 'NodePort',
|
||||
},
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: 'Service',
|
||||
name: 'my-app-3',
|
||||
type: 'LoadBalancer',
|
||||
},
|
||||
];
|
||||
|
||||
const updateDeploymentToStatefulSetSummaries: Array<Summary> = [
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: 'Deployment',
|
||||
name: 'my-app',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'StatefulSet',
|
||||
name: 'my-app',
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
name: 'my-app',
|
||||
},
|
||||
];
|
||||
|
||||
describe('getUpdateAppSummaries', () => {
|
||||
const tests: {
|
||||
oldFormValues: ApplicationFormValues;
|
||||
newFormValues: ApplicationFormValues;
|
||||
expected: Array<Summary>;
|
||||
title: string;
|
||||
}[] = [
|
||||
{
|
||||
oldFormValues: complicatedStatefulSet,
|
||||
newFormValues: complicatedStatefulSetNoServices,
|
||||
expected: updateComplicatedStatefulSetSummaries,
|
||||
title:
|
||||
'should return update summaries for removing services from statefulset',
|
||||
},
|
||||
{
|
||||
oldFormValues: simpleDeployment,
|
||||
newFormValues: complicatedStatefulSetNoServices,
|
||||
expected: updateDeploymentToStatefulSetSummaries,
|
||||
title:
|
||||
'should return update summaries for changing deployment to statefulset',
|
||||
},
|
||||
];
|
||||
tests.forEach((test) => {
|
||||
// eslint-disable-next-line jest/valid-title
|
||||
it(test.title, () => {
|
||||
expect(
|
||||
getAppResourceSummaries(test.newFormValues, test.oldFormValues)
|
||||
).toEqual(test.expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,362 @@
|
|||
import { Ingress } from '@/react/kubernetes/ingresses/types';
|
||||
|
||||
import { ServiceFormValues } from '../../CreateView/application-services/types';
|
||||
import { ApplicationFormValues } from '../../types';
|
||||
import {
|
||||
generateNewIngressesFromFormPaths,
|
||||
getServicePatchPayload,
|
||||
} from '../../CreateView/application-services/utils';
|
||||
|
||||
import {
|
||||
KubernetesResourceType,
|
||||
KubernetesResourceAction,
|
||||
Summary,
|
||||
} from './types';
|
||||
|
||||
export function getArticle(
|
||||
resourceType: KubernetesResourceType,
|
||||
resourceAction: KubernetesResourceAction
|
||||
) {
|
||||
if (resourceAction === 'Delete' || resourceAction === 'Update') {
|
||||
return 'the';
|
||||
}
|
||||
if (resourceAction === 'Create' && resourceType === 'Ingress') {
|
||||
return 'an';
|
||||
}
|
||||
return 'a';
|
||||
}
|
||||
|
||||
/**
|
||||
* generateResourceSummaryList maps formValues to create and update summaries
|
||||
*/
|
||||
export function getAppResourceSummaries(
|
||||
newFormValues: ApplicationFormValues,
|
||||
oldFormValues?: ApplicationFormValues
|
||||
): Array<Summary> {
|
||||
if (!oldFormValues) {
|
||||
return getCreatedApplicationResourcesNew(newFormValues);
|
||||
}
|
||||
return getUpdatedApplicationResources(newFormValues, oldFormValues);
|
||||
}
|
||||
|
||||
function getCreatedApplicationResourcesNew(
|
||||
formValues: ApplicationFormValues
|
||||
): Array<Summary> {
|
||||
// app summary
|
||||
const appSummary: Summary = {
|
||||
action: 'Create',
|
||||
kind: formValues.ApplicationType,
|
||||
name: formValues.Name,
|
||||
};
|
||||
|
||||
// service summaries
|
||||
const serviceFormSummaries: Array<Summary> =
|
||||
formValues.Services?.map((service) => ({
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: service.Name || '',
|
||||
type: service.Type,
|
||||
})) || [];
|
||||
// statefulsets require a headless service (https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#limitations)
|
||||
// create a headless service summary if the application is a statefulset
|
||||
const headlessSummary: Array<Summary> =
|
||||
formValues.ApplicationType === 'StatefulSet'
|
||||
? [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: `headless-${formValues.Name}`,
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const serviceSummaries = [...serviceFormSummaries, ...headlessSummary];
|
||||
|
||||
// ingress summaries
|
||||
const ingressesSummaries: Array<Summary> =
|
||||
formValues.Services?.flatMap((service) => {
|
||||
// a single service port can have multiple ingress paths (and even use different ingresses)
|
||||
const servicePathsIngressNames = service.Ports.flatMap(
|
||||
(port) => port.ingressPaths?.map((path) => path.IngressName) || []
|
||||
);
|
||||
const uniqueIngressNames = [...new Set(servicePathsIngressNames)];
|
||||
return uniqueIngressNames.map((ingressName) => ({
|
||||
action: 'Update',
|
||||
kind: 'Ingress',
|
||||
name: ingressName || '',
|
||||
}));
|
||||
}) || [];
|
||||
|
||||
// persistent volume claim (pvc) summaries
|
||||
const pvcSummaries: Array<Summary> =
|
||||
// apps with a isolated data access policy are statefulsets.
|
||||
// statefulset pvcs are defined in spec.volumeClaimTemplates.
|
||||
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-storage
|
||||
formValues.DataAccessPolicy === 'Shared'
|
||||
? formValues.PersistedFolders?.map((volume) => ({
|
||||
action: 'Create',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
name:
|
||||
volume.existingVolume?.PersistentVolumeClaim.Name ||
|
||||
volume.persistentVolumeClaimName ||
|
||||
'',
|
||||
})) || []
|
||||
: [];
|
||||
|
||||
// horizontal pod autoscaler summaries
|
||||
const hpaSummary: Array<Summary> =
|
||||
formValues.AutoScaler?.isUsed === true &&
|
||||
formValues.DeploymentType !== 'Global'
|
||||
? [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
name: formValues.Name,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
appSummary,
|
||||
...serviceSummaries,
|
||||
...ingressesSummaries,
|
||||
...pvcSummaries,
|
||||
...hpaSummary,
|
||||
];
|
||||
}
|
||||
|
||||
function getUpdatedApplicationResources(
|
||||
newFormValues: ApplicationFormValues,
|
||||
oldFormValues: ApplicationFormValues
|
||||
) {
|
||||
// app summaries
|
||||
const updateAppSummaries: Array<Summary> =
|
||||
oldFormValues.ApplicationType !== newFormValues.ApplicationType
|
||||
? [
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: oldFormValues.ApplicationType,
|
||||
name: oldFormValues.Name,
|
||||
},
|
||||
{
|
||||
action: 'Create',
|
||||
kind: newFormValues.ApplicationType,
|
||||
name: newFormValues.Name,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
action: 'Update',
|
||||
kind: newFormValues.ApplicationType,
|
||||
name: newFormValues.Name,
|
||||
},
|
||||
];
|
||||
|
||||
// service summaries
|
||||
const serviceSummaries: Array<Summary> = getServiceUpdateResourceSummary(
|
||||
oldFormValues.Services,
|
||||
newFormValues.Services
|
||||
);
|
||||
|
||||
// ingress summaries
|
||||
const oldServicePorts = oldFormValues.Services?.flatMap(
|
||||
(service) => service.Ports
|
||||
);
|
||||
const oldIngresses = generateNewIngressesFromFormPaths(
|
||||
oldFormValues.OriginalIngresses,
|
||||
oldServicePorts,
|
||||
oldServicePorts
|
||||
);
|
||||
const newServicePorts = newFormValues.Services?.flatMap(
|
||||
(service) => service.Ports
|
||||
);
|
||||
const newIngresses = generateNewIngressesFromFormPaths(
|
||||
newFormValues.OriginalIngresses,
|
||||
newServicePorts,
|
||||
oldServicePorts
|
||||
);
|
||||
const ingressSummaries = getIngressUpdateSummary(oldIngresses, newIngresses);
|
||||
|
||||
// persistent volume claim (pvc) summaries
|
||||
const pvcSummaries: Array<Summary> =
|
||||
// apps with a isolated data access policy are statefulsets.
|
||||
// statefulset pvcs are defined in spec.volumeClaimTemplates.
|
||||
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-storage
|
||||
newFormValues.DataAccessPolicy === 'Shared'
|
||||
? newFormValues.PersistedFolders?.flatMap((newVolume) => {
|
||||
const oldVolume = oldFormValues.PersistedFolders?.find(
|
||||
(oldVolume) =>
|
||||
oldVolume.persistentVolumeClaimName ===
|
||||
newVolume.persistentVolumeClaimName
|
||||
);
|
||||
if (!oldVolume) {
|
||||
return [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
name:
|
||||
newVolume.existingVolume?.PersistentVolumeClaim.Name ||
|
||||
newVolume.persistentVolumeClaimName ||
|
||||
'',
|
||||
},
|
||||
];
|
||||
}
|
||||
// updating a pvc is not supported
|
||||
return [];
|
||||
}) || []
|
||||
: [];
|
||||
|
||||
// TODO: horizontal pod autoscaler summaries
|
||||
const createHPASummary: Array<Summary> =
|
||||
newFormValues.AutoScaler?.isUsed && !oldFormValues.AutoScaler?.isUsed
|
||||
? [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
name: newFormValues.Name,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const deleteHPASummary: Array<Summary> =
|
||||
!newFormValues.AutoScaler?.isUsed && oldFormValues.AutoScaler?.isUsed
|
||||
? [
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
name: oldFormValues.Name,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const isHPAUpdated =
|
||||
newFormValues.AutoScaler?.isUsed &&
|
||||
oldFormValues.AutoScaler?.isUsed &&
|
||||
(newFormValues.AutoScaler?.minReplicas !==
|
||||
oldFormValues.AutoScaler?.minReplicas ||
|
||||
newFormValues.AutoScaler?.maxReplicas !==
|
||||
oldFormValues.AutoScaler?.maxReplicas ||
|
||||
newFormValues.AutoScaler?.targetCpuUtilizationPercentage !==
|
||||
oldFormValues.AutoScaler?.targetCpuUtilizationPercentage);
|
||||
const updateHPASummary: Array<Summary> = isHPAUpdated
|
||||
? [
|
||||
{
|
||||
action: 'Update',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
name: newFormValues.Name,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const hpaSummaries = [
|
||||
...createHPASummary,
|
||||
...deleteHPASummary,
|
||||
...updateHPASummary,
|
||||
];
|
||||
|
||||
return [
|
||||
...updateAppSummaries,
|
||||
...serviceSummaries,
|
||||
...ingressSummaries,
|
||||
...pvcSummaries,
|
||||
...hpaSummaries,
|
||||
];
|
||||
}
|
||||
|
||||
// getServiceUpdateResourceSummary replicates KubernetesServiceService.patch
|
||||
function getServiceUpdateResourceSummary(
|
||||
oldServices?: Array<ServiceFormValues>,
|
||||
newServices?: Array<ServiceFormValues>
|
||||
): Array<Summary> {
|
||||
const updateAndCreateSummaries =
|
||||
newServices?.flatMap<Summary>((newService) => {
|
||||
const oldServiceMatched = oldServices?.find(
|
||||
(oldService) => oldService.Name === newService.Name
|
||||
);
|
||||
if (oldServiceMatched) {
|
||||
return getServiceUpdateSummary(oldServiceMatched, newService);
|
||||
}
|
||||
return [
|
||||
{
|
||||
action: 'Create',
|
||||
kind: 'Service',
|
||||
name: newService.Name || '',
|
||||
type: newService.Type || 'ClusterIP',
|
||||
},
|
||||
];
|
||||
}) || [];
|
||||
|
||||
const deleteSummaries =
|
||||
oldServices?.flatMap<Summary>((oldService) => {
|
||||
const newServiceMatched = newServices?.find(
|
||||
(newService) => newService.Name === oldService.Name
|
||||
);
|
||||
if (newServiceMatched) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
action: 'Delete',
|
||||
kind: 'Service',
|
||||
name: oldService.Name || '',
|
||||
type: oldService.Type || 'ClusterIP',
|
||||
},
|
||||
];
|
||||
}) || [];
|
||||
|
||||
return [...updateAndCreateSummaries, ...deleteSummaries];
|
||||
}
|
||||
|
||||
function getServiceUpdateSummary(
|
||||
oldService: ServiceFormValues,
|
||||
newService: ServiceFormValues
|
||||
): Array<Summary> {
|
||||
const payload = getServicePatchPayload(oldService, newService);
|
||||
if (payload.length) {
|
||||
return [
|
||||
{
|
||||
action: 'Update',
|
||||
kind: 'Service',
|
||||
name: oldService.Name || '',
|
||||
type: oldService.Type || 'ClusterIP',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getIngressUpdateSummary(
|
||||
oldIngresses: Array<Ingress>,
|
||||
newIngresses: Array<Ingress>
|
||||
): Array<Summary> {
|
||||
const ingressesSummaries = newIngresses.flatMap((newIng) => {
|
||||
const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
|
||||
if (oldIng) {
|
||||
return getIngressUpdateResourceSummary(oldIng, newIng);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return ingressesSummaries;
|
||||
}
|
||||
|
||||
// getIngressUpdateResourceSummary checks if any ingress paths have been changed
|
||||
function getIngressUpdateResourceSummary(
|
||||
oldIngress: Ingress,
|
||||
newIngress: Ingress
|
||||
): Array<Summary> {
|
||||
const newIngressPaths = newIngress.Paths?.flatMap((path) => path.Path) || [];
|
||||
const oldIngressPaths = oldIngress.Paths?.flatMap((path) => path.Path) || [];
|
||||
const isAnyNewPathMissingOldPath = newIngressPaths.some(
|
||||
(path) => !oldIngressPaths.includes(path)
|
||||
);
|
||||
const isAnyOldPathMissingNewPath = oldIngressPaths.some(
|
||||
(path) => !newIngressPaths.includes(path)
|
||||
);
|
||||
if (isAnyNewPathMissingOldPath || isAnyOldPathMissingNewPath) {
|
||||
return [
|
||||
{
|
||||
action: 'Update',
|
||||
kind: 'Ingress',
|
||||
name: oldIngress.Name,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
|
@ -221,8 +220,7 @@ export function PersistedFolderItem({
|
|||
function isToggleVolumeTypeVisible() {
|
||||
return (
|
||||
!(isEdit && isExistingPersistedFolder()) && // if it's not an edit of an existing persisted folder
|
||||
applicationValues.ApplicationType !==
|
||||
KubernetesApplicationTypes.STATEFULSET && // and if it's not a statefulset
|
||||
applicationValues.ApplicationType !== 'StatefulSet' && // and if it's not a statefulset
|
||||
applicationValues.Containers.length <= 1 // and if there is only one container);
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { useMemo } from 'react';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { Option } from '@@/form-components/PortainerSelect';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
|
@ -43,11 +43,7 @@ export function PersistedFoldersFormSection({
|
|||
const PVCOptions = usePVCOptions(availableVolumes);
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
title="Persisted folders"
|
||||
titleSize="sm"
|
||||
titleClassName="control-label !text-[0.9em]"
|
||||
>
|
||||
<FormSection title="Persisted folders" titleSize="sm">
|
||||
{storageClasses.length === 0 && (
|
||||
<TextTip color="blue">
|
||||
No storage option is available to persist data, contact your
|
||||
|
@ -81,17 +77,21 @@ export function PersistedFoldersFormSection({
|
|||
initialValues={initialValues}
|
||||
/>
|
||||
)}
|
||||
itemBuilder={() => ({
|
||||
persistentVolumeClaimName:
|
||||
availableVolumes[0]?.PersistentVolumeClaim.Name || '',
|
||||
containerPath: '',
|
||||
size: '',
|
||||
sizeUnit: 'GB',
|
||||
storageClass: storageClasses[0],
|
||||
useNewVolume: true,
|
||||
existingVolume: undefined,
|
||||
needsDeletion: false,
|
||||
})}
|
||||
itemBuilder={() => {
|
||||
const newVolumeClaimName = `${applicationValues.Name}-${uuidv4()}`;
|
||||
return {
|
||||
persistentVolumeClaimName:
|
||||
availableVolumes[0]?.PersistentVolumeClaim.Name ||
|
||||
newVolumeClaimName,
|
||||
containerPath: '',
|
||||
size: '',
|
||||
sizeUnit: 'GB',
|
||||
storageClass: storageClasses[0],
|
||||
useNewVolume: true,
|
||||
existingVolume: undefined,
|
||||
needsDeletion: false,
|
||||
};
|
||||
}}
|
||||
addLabel="Add persisted folder"
|
||||
canUndoDelete={isEdit}
|
||||
/>
|
||||
|
@ -100,9 +100,7 @@ export function PersistedFoldersFormSection({
|
|||
|
||||
function isDeleteButtonHidden() {
|
||||
return (
|
||||
(isEdit &&
|
||||
applicationValues.ApplicationType ===
|
||||
KubernetesApplicationTypes.STATEFULSET) ||
|
||||
(isEdit && applicationValues.ApplicationType === 'StatefulSet') ||
|
||||
applicationValues.Containers.length >= 1
|
||||
);
|
||||
}
|
||||
|
|
|
@ -35,11 +35,7 @@ export function PlacementFormSection({ values, onChange, errors }: Props) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<FormSection
|
||||
title="Placement preferences and constraints"
|
||||
titleSize="sm"
|
||||
titleClassName="control-label !text-[0.9em]"
|
||||
>
|
||||
<FormSection title="Placement preferences and constraints" titleSize="sm">
|
||||
{values.placements?.length > 0 && (
|
||||
<TextTip color="blue">
|
||||
Deploy this application on nodes that respect <b>ALL</b> of the
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue