1
0
Fork 0
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

This commit is contained in:
Ali 2024-01-05 15:42:36 +13:00 committed by GitHub
parent 7a4314032a
commit abf517de28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1461 additions and 661 deletions

View file

@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
useIngressControllers,
@ -10,12 +9,7 @@ import {
import { FormSection } from '@@/form-components/FormSection';
import {
ServiceFormValues,
ServiceTypeAngularEnum,
ServiceTypeOption,
ServiceTypeValue,
} from './types';
import { ServiceFormValues, ServiceTypeOption, ServiceType } from './types';
import { generateUniqueName } from './utils';
import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm';
import { ServiceTabs } from './components/ServiceTabs';
@ -24,15 +18,6 @@ import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesFo
import { ServiceTabLabel } from './components/ServiceTabLabel';
import { PublishingExplaination } from './PublishingExplaination';
const serviceTypeEnumsToValues: Record<
ServiceTypeAngularEnum,
ServiceTypeValue
> = {
[KubernetesApplicationPublishingTypes.CLUSTER_IP]: 'ClusterIP',
[KubernetesApplicationPublishingTypes.NODE_PORT]: 'NodePort',
[KubernetesApplicationPublishingTypes.LOAD_BALANCER]: 'LoadBalancer',
};
interface Props {
values: ServiceFormValues[];
onChange: (services: ServiceFormValues[]) => void;
@ -53,7 +38,7 @@ export function KubeServicesForm({
namespace,
}: Props) {
const [selectedServiceType, setSelectedServiceType] =
useState<ServiceTypeValue>('ClusterIP');
useState<ServiceType>('ClusterIP');
// start loading ingresses and controllers early to reduce perceived loading time
const environmentId = useEnvironmentId();
@ -195,17 +180,17 @@ function getUniqNames(appName: string, services: ServiceFormValues[]) {
*/
function getServiceTypeCounts(
services: ServiceFormValues[]
): Record<ServiceTypeValue, number> {
): Record<ServiceType, number> {
return services.reduce(
(acc, service) => {
const type = serviceTypeEnumsToValues[service.Type];
const type = service.Type;
const count = acc[type];
return {
...acc,
[type]: count ? count + 1 : 1,
};
},
{} as Record<ServiceTypeValue, number>
{} as Record<ServiceType, number>
);
}
@ -215,10 +200,10 @@ function getServiceTypeCounts(
function getServiceTypeHasErrors(
services: ServiceFormValues[],
errors: FormikErrors<ServiceFormValues[] | undefined>
): Record<ServiceTypeValue, boolean> {
): Record<ServiceType, boolean> {
return services.reduce(
(acc, service, index) => {
const type = serviceTypeEnumsToValues[service.Type];
const type = service.Type;
const serviceHasErrors = !!errors?.[index];
// if the service type already has an error, don't overwrite it
if (acc[type] === true) return acc;
@ -228,6 +213,6 @@ function getServiceTypeHasErrors(
[type]: serviceHasErrors,
};
},
{} as Record<ServiceTypeValue, boolean>
{} as Record<ServiceType, boolean>
);
}

View file

@ -88,10 +88,7 @@ export function ClusterIpServiceForm({
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
const newValue = e.target.valueAsNumber;
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
@ -113,10 +110,7 @@ export function ClusterIpServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}

View file

@ -1,8 +1,6 @@
import { Plus } from 'lucide-react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
@ -36,8 +34,7 @@ export function ClusterIpServicesForm({
isEditMode,
}: Props) {
const clusterIPServiceCount = services.filter(
(service) =>
service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP
(service) => service.Type === 'ClusterIP'
).length;
return (
<Card className="pb-5">
@ -50,8 +47,7 @@ export function ClusterIpServicesForm({
{clusterIPServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.CLUSTER_IP ? (
service.Type === 'ClusterIP' ? (
<ClusterIpServiceForm
key={index}
serviceName={service.Name}
@ -86,7 +82,7 @@ export function ClusterIpServicesForm({
services.length + 1,
services
);
newService.Type = KubernetesApplicationPublishingTypes.CLUSTER_IP;
newService.Type = 'ClusterIP';
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;

View file

@ -22,7 +22,7 @@ export function ContainerPortInput({
type="number"
className="form-control min-w-max"
name={`container_port_${portIndex}`}
placeholder="80"
placeholder="e.g. 80"
min="1"
max="65535"
value={value ?? ''}

View file

@ -22,7 +22,7 @@ export function ServicePortInput({
type="number"
className="form-control min-w-max"
name={`service_port_${portIndex}`}
placeholder="80"
placeholder="e.g. 80"
min="1"
max="65535"
value={value ?? ''}

View file

@ -1,11 +1,11 @@
import clsx from 'clsx';
import { ServiceTypeOption, ServiceTypeValue } from '../types';
import { ServiceTypeOption, ServiceType } from '../types';
type Props = {
serviceTypeOptions: ServiceTypeOption[];
selectedServiceType: ServiceTypeValue;
setSelectedServiceType: (serviceTypeValue: ServiceTypeValue) => void;
selectedServiceType: ServiceType;
setSelectedServiceType: (serviceTypeValue: ServiceType) => void;
};
export function ServiceTabs({
@ -32,7 +32,7 @@ export function ServiceTabs({
value={serviceTypeOptions[index].value}
checked={selectedServiceType === serviceTypeOptions[index].value}
onChange={(e) =>
setSelectedServiceType(e.target.value as ServiceTypeValue)
setSelectedServiceType(e.target.value as ServiceType)
}
/>
{label}

View file

@ -1,7 +1,5 @@
import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { ServiceFormValues, ServicePort } from './types';
import { prependWithSlash } from './utils';
@ -46,11 +44,7 @@ export function kubeServicesValidation(
Namespace: string(),
Name: string(),
StackName: string(),
Type: mixed().oneOf([
KubernetesApplicationPublishingTypes.CLUSTER_IP,
KubernetesApplicationPublishingTypes.NODE_PORT,
KubernetesApplicationPublishingTypes.LOAD_BALANCER,
]),
Type: mixed().oneOf(['ClusterIP', 'NodePort', 'LoadBalancer']),
ClusterIP: string(),
ApplicationName: string(),
ApplicationOwner: string(),
@ -61,6 +55,7 @@ export function kubeServicesValidation(
object({
port: number()
.required('Service port number is required.')
.typeError('Service port number is required.')
.min(1, 'Service port number must be inside the range 1-65535.')
.max(65535, 'Service port number must be inside the range 1-65535.')
.test(
@ -93,6 +88,7 @@ export function kubeServicesValidation(
),
targetPort: number()
.required('Container port number is required.')
.typeError('Container port number is required.')
.min(1, 'Container port number must be inside the range 1-65535.')
.max(
65535,
@ -116,8 +112,7 @@ export function kubeServicesValidation(
);
if (
matchingService === undefined ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
matchingService.Type !== 'NodePort'
) {
return true;
}
@ -143,8 +138,7 @@ export function kubeServicesValidation(
if (
matchingService === undefined ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
matchingService.Type !== 'NodePort'
) {
return true;
}
@ -163,8 +157,7 @@ export function kubeServicesValidation(
const formNodePortsWithoutCurrentService = formServices
.filter(
(formService) =>
formService.Type ===
KubernetesApplicationPublishingTypes.NODE_PORT &&
formService.Type === 'NodePort' &&
formService.Name !== matchingService.Name
)
.flatMap((formService) => formService.Ports)
@ -187,11 +180,7 @@ export function kubeServicesValidation(
context.parent as ServicePort,
formServices
);
if (
!matchingService ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT
) {
if (!matchingService || matchingService.Type !== 'NodePort') {
return true;
}
return nodePort >= 30000;
@ -209,11 +198,7 @@ export function kubeServicesValidation(
context.parent as ServicePort,
formServices
);
if (
!matchingService ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT
) {
if (!matchingService || matchingService.Type !== 'NodePort') {
return true;
}
return nodePort <= 32767;

View file

@ -93,10 +93,7 @@ export function LoadBalancerServiceForm({
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
const newValue = e.target.valueAsNumber;
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
@ -119,10 +116,7 @@ export function LoadBalancerServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}
@ -140,7 +134,7 @@ export function LoadBalancerServiceForm({
type="number"
className="form-control min-w-max"
name={`loadbalancer_port_${portIndex}`}
placeholder="80"
placeholder="e.g. 80"
min="1"
max="65535"
value={servicePort.port || ''}
@ -148,10 +142,7 @@ export function LoadBalancerServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}

View file

@ -1,7 +1,6 @@
import { Plus, RefreshCw } from 'lucide-react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -49,8 +48,7 @@ export function LoadBalancerServicesForm({
);
const loadBalancerServiceCount = services.filter(
(service) =>
service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER
(service) => service.Type === 'LoadBalancer'
).length;
return (
<Card className="pb-5">
@ -95,8 +93,7 @@ export function LoadBalancerServicesForm({
{loadBalancerServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER ? (
service.Type === 'LoadBalancer' ? (
<LoadBalancerServiceForm
key={index}
serviceName={service.Name}
@ -131,8 +128,7 @@ export function LoadBalancerServicesForm({
services.length + 1,
services
);
newService.Type =
KubernetesApplicationPublishingTypes.LOAD_BALANCER;
newService.Type = 'LoadBalancer';
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;

View file

@ -94,10 +94,7 @@ export function NodePortServiceForm({
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
const newValue = e.target.valueAsNumber;
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
@ -120,10 +117,7 @@ export function NodePortServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}
@ -139,7 +133,7 @@ export function NodePortServiceForm({
type="number"
className="form-control min-w-max"
name={`node_port_${portIndex}`}
placeholder="30080"
placeholder="e.g. 30080"
min="30000"
max="32767"
value={servicePort.nodePort ?? ''}

View file

@ -1,8 +1,6 @@
import { FormikErrors } from 'formik';
import { Plus } from 'lucide-react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
@ -36,7 +34,7 @@ export function NodePortServicesForm({
isEditMode,
}: Props) {
const nodePortServiceCount = services.filter(
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
(service) => service.Type === 'NodePort'
).length;
return (
<Card className="pb-5">
@ -48,8 +46,7 @@ export function NodePortServicesForm({
{nodePortServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.NODE_PORT ? (
service.Type === 'NodePort' ? (
<NodePortServiceForm
key={index}
serviceName={service.Name}
@ -84,7 +81,7 @@ export function NodePortServicesForm({
services.length + 1,
services
);
newService.Type = KubernetesApplicationPublishingTypes.NODE_PORT;
newService.Type = 'NodePort';
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;

View file

@ -1,9 +1,7 @@
import { ReactNode } from 'react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
export interface ServicePort {
port?: number;
port: number;
targetPort?: number;
nodePort?: number;
serviceName?: string;
@ -12,9 +10,6 @@ export interface ServicePort {
ingressPaths?: ServicePortIngressPath[];
}
export type ServiceTypeAngularEnum =
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
export type ServicePortIngressPath = {
IngressName?: string;
Host?: string;
@ -24,7 +19,7 @@ export type ServicePortIngressPath = {
export type ServiceFormValues = {
Headless: boolean;
Ports: ServicePort[];
Type: ServiceTypeAngularEnum;
Type: ServiceType;
Ingress: boolean;
ClusterIP?: string;
ApplicationName?: string;
@ -36,9 +31,9 @@ export type ServiceFormValues = {
Namespace?: string;
};
export type ServiceTypeValue = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
export type ServiceType = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
export type ServiceTypeOption = {
value: ServiceTypeValue;
value: ServiceType;
label: ReactNode;
};

View file

@ -1,10 +1,21 @@
import { Ingress } from '@/react/kubernetes/ingresses/types';
import { compare } from 'fast-json-patch';
import { Service, ServiceSpec } from 'kubernetes-types/core/v1';
import { ObjectMeta } from 'kubernetes-types/meta/v1';
import angular from 'angular';
import { Ingress as IngressFormValues } from '@/react/kubernetes/ingresses/types';
import {
appNameLabel,
appOwnerLabel,
appStackNameLabel,
} from '../../constants';
import { ServiceFormValues, ServicePort } from './types';
export function newPort(serviceName?: string) {
export function newPort(serviceName?: string): ServicePort {
return {
port: undefined,
port: 80,
targetPort: undefined,
name: '',
protocol: 'TCP',
@ -43,7 +54,7 @@ export const serviceFormDefaultValues: ServiceFormValues = {
Name: '',
StackName: '',
Ports: [],
Type: 1, // clusterip type as default
Type: 'ClusterIP',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
@ -54,16 +65,16 @@ export const serviceFormDefaultValues: ServiceFormValues = {
/**
* Generates new Ingress objects from form path data
* @param {Ingress[]} oldIngresses - The old Ingress objects
* @param {IngressFormValues[]} oldIngresses - The old Ingress objects
* @param {ServicePort[]} newServicesPorts - The new ServicePort objects from the form
* @param {ServicePort[]} oldServicesPorts - The old ServicePort objects
* @returns {Ingress[]} The new Ingress objects
* @returns {IngressFormValues[]} The new Ingress objects
*/
export function generateNewIngressesFromFormPaths(
oldIngresses?: Ingress[],
oldIngresses?: IngressFormValues[],
newServicesPorts?: ServicePort[],
oldServicesPorts?: ServicePort[]
): Ingress[] {
): IngressFormValues[] {
// filter the ports to only the ones that have an ingress
const oldIngressPaths = oldServicesPorts
?.flatMap((port) => port.ingressPaths)
@ -77,7 +88,7 @@ export function generateNewIngressesFromFormPaths(
}
// remove the old paths from the newIngresses copy
const newIngresses = structuredClone(oldIngresses) ?? [];
const newIngresses: IngressFormValues[] = angular.copy(oldIngresses) ?? []; // the current jest version doesn't support structured cloning, so we need to use angular.copy
oldIngressPaths?.forEach((oldIngressPath) => {
if (!oldIngressPath?.Path) return;
const newMatchingIng = newIngresses?.find(
@ -151,3 +162,57 @@ export function prependWithSlash(path?: string) {
if (!path) return '';
return path.startsWith('/') ? path : `/${path}`;
}
export function getServicePatchPayload(
oldService: ServiceFormValues,
newService: ServiceFormValues
) {
const oldPayload = getServicePayload(oldService);
const newPayload = getServicePayload(newService);
const payload = compare(oldPayload, newPayload);
return payload;
}
function getServicePayload(service: ServiceFormValues): Service {
if (!service.Name || !service.Namespace) {
throw new Error('Service name and namespace are required');
}
// metadata
const labels: Record<string, string> = {};
if (service.ApplicationName) {
labels[appNameLabel] = service.ApplicationName;
}
if (service.ApplicationOwner) {
labels[appOwnerLabel] = service.ApplicationOwner;
}
if (service.StackName) {
labels[appStackNameLabel] = service.StackName;
}
const metadata: ObjectMeta = {
name: service.Name,
namespace: service.Namespace,
labels,
};
// spec
const ports = service.Headless ? [] : service.Ports;
const selector = service.Selector;
const clusterIP = service.Headless ? 'None' : service.ClusterIP;
const type = service.Headless ? 'ClusterIP' : service.Type;
const spec: ServiceSpec = {
ports,
selector,
clusterIP,
type,
};
const servicePayload: Service = {
apiVersion: 'v1',
kind: 'Service',
metadata,
spec,
};
return servicePayload;
}