1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

refactor(kubernetes): move react codebase [EE-3349] (#7953)

This commit is contained in:
Chaim Lev-Ari 2022-11-07 08:03:11 +02:00 committed by GitHub
parent 2868da296a
commit 77c29ff87e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 18 additions and 21 deletions

View file

@ -1,11 +1,11 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { IngressesDatatableView } from '@/kubernetes/react/views/networks/ingresses/IngressDatatable';
import { CreateIngressView } from '@/kubernetes/react/views/networks/ingresses/CreateIngressView';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])

View file

@ -1,742 +0,0 @@
import { useState, useEffect, useMemo, ReactNode } from 'react';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { v4 as uuidv4 } from 'uuid';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types';
import { useServices } from '@/kubernetes/react/views/networks/services/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import { Link } from '@@/Link';
import { PageHeader } from '@@/PageHeader';
import { Option } from '@@/form-components/Input/Select';
import { Button } from '@@/buttons';
import { Ingress, IngressController } from '../types';
import {
useCreateIngress,
useIngresses,
useUpdateIngress,
useIngressControllers,
} from '../queries';
import { Rule, Path, Host } from './types';
import { IngressForm } from './IngressForm';
import {
prepareTLS,
preparePaths,
prepareAnnotations,
prepareRuleFromIngress,
checkIfPathExistsWithHost,
} from './utils';
export function CreateIngressView() {
const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams();
const router = useRouter();
const isEdit = !!params.namespace;
const [namespace, setNamespace] = useState<string>(params.namespace || '');
const [ingressRule, setIngressRule] = useState<Rule>({} as Rule);
const [errors, setErrors] = useState<Record<string, ReactNode>>(
{} as Record<string, string>
);
const namespacesResults = useNamespaces(environmentId);
const servicesResults = useServices(environmentId, namespace);
const configResults = useConfigurations(environmentId, namespace);
const ingressesResults = useIngresses(
environmentId,
namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : []
);
const ingressControllersResults = useIngressControllers(
environmentId,
namespace
);
const createIngressMutation = useCreateIngress();
const updateIngressMutation = useUpdateIngress();
const isLoading =
(servicesResults.isLoading &&
configResults.isLoading &&
namespacesResults.isLoading &&
ingressesResults.isLoading &&
ingressControllersResults.isLoading) ||
(isEdit && !ingressRule.IngressName);
const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] =
useMemo((): [
string[],
Ingress[],
Record<string, number>,
Record<string, string>
] => {
const ruleCounterByNamespace: Record<string, number> = {};
const hostWithTLS: Record<string, string> = {};
ingressesResults.data?.forEach((ingress) => {
ingress.TLS?.forEach((tls) => {
tls.Hosts.forEach((host) => {
hostWithTLS[host] = tls.SecretName;
});
});
});
const ingressNames: string[] = [];
ingressesResults.data?.forEach((ing) => {
ruleCounterByNamespace[ing.Namespace] =
ruleCounterByNamespace[ing.Namespace] || 0;
const n = ing.Name.match(/^(.*)-(\d+)$/);
if (n?.length === 3) {
ruleCounterByNamespace[ing.Namespace] = Math.max(
ruleCounterByNamespace[ing.Namespace],
Number(n[2])
);
}
if (ing.Namespace === namespace) {
ingressNames.push(ing.Name);
}
});
return [
ingressNames || [],
ingressesResults.data || [],
ruleCounterByNamespace,
hostWithTLS,
];
}, [ingressesResults.data, namespace]);
const namespacesOptions: Option<string>[] = [
{ label: 'Select a namespace', value: '' },
];
Object.entries(namespacesResults?.data || {}).forEach(([ns, val]) => {
if (!val.IsSystem) {
namespacesOptions.push({
label: ns,
value: ns,
});
}
});
const clusterIpServices = useMemo(
() => servicesResults.data?.filter((s) => s.Type === 'ClusterIP'),
[servicesResults.data]
);
const servicesOptions = useMemo(
() =>
clusterIpServices?.map((service) => ({
label: service.Name,
value: service.Name,
})),
[clusterIpServices]
);
const serviceOptions = [
{ label: 'Select a service', value: '' },
...(servicesOptions || []),
];
const servicePorts = useMemo(
() =>
clusterIpServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
value: String(port.Port),
})),
])
)
: {},
[clusterIpServices]
);
const existingIngressClass = useMemo(
() =>
ingressControllersResults.data?.find(
(i) =>
i.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && ingressRule.IngressClassName === '')
),
[ingressControllersResults.data, ingressRule.IngressClassName]
);
const ingressClassOptions: Option<string>[] = [
{ label: 'Select an ingress class', value: '' },
...(ingressControllersResults.data
?.filter((cls) => cls.Availability)
.map((cls) => ({
label: cls.ClassName,
value: cls.ClassName,
})) || []),
];
if (
(!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName &&
!ingressControllersResults.isLoading
) {
const optionLabel = !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND`
: `${ingressRule.IngressClassName} - DISALLOWED`;
ingressClassOptions.push({
label: optionLabel,
value: ingressRule.IngressClassName,
});
}
const matchedConfigs = configResults?.data?.filter(
(config) =>
config.SecretType === 'kubernetes.io/tls' &&
config.Namespace === namespace
);
const tlsOptions: Option<string>[] = useMemo(
() => [
{ label: 'No TLS', value: '' },
...(matchedConfigs?.map((config) => ({
label: config.Name,
value: config.Name,
})) || []),
],
[matchedConfigs]
);
useEffect(() => {
if (
!!params.name &&
ingressesResults.data &&
!ingressRule.IngressName &&
!ingressControllersResults.isLoading &&
!ingressControllersResults.isLoading
) {
// if it is an edit screen, prepare the rule from the ingress
const ing = ingressesResults.data?.find(
(ing) => ing.Name === params.name && ing.Namespace === params.namespace
);
if (ing) {
const type = ingressControllersResults.data?.find(
(c) =>
c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName)
)?.Type;
const r = prepareRuleFromIngress(ing, type);
r.IngressType = type || r.IngressType;
setIngressRule(r);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
params.name,
ingressesResults.data,
ingressControllersResults.data,
ingressRule.IngressName,
params.namespace,
]);
useEffect(() => {
// for each host, if the tls selection doesn't exist as an option, change it to the first option
if (ingressRule?.Hosts?.length) {
ingressRule.Hosts.forEach((host, hIndex) => {
const secret = host.Secret || '';
const tlsOptionVals = tlsOptions.map((o) => o.value);
if (tlsOptions?.length && !tlsOptionVals?.includes(secret)) {
handleTLSChange(hIndex, tlsOptionVals[0]);
}
});
}
}, [tlsOptions, ingressRule.Hosts]);
useEffect(() => {
// for each path in each host, if the service port doesn't exist as an option, change it to the first option
if (ingressRule?.Hosts?.length) {
ingressRule.Hosts.forEach((host, hIndex) => {
host?.Paths?.forEach((path, pIndex) => {
const serviceName = path.ServiceName;
const currentServicePorts = servicePorts[serviceName]?.map(
(p) => p.value
);
if (
currentServicePorts?.length &&
!currentServicePorts?.includes(String(path.ServicePort))
) {
handlePathChange(
hIndex,
pIndex,
'ServicePort',
currentServicePorts[0]
);
}
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ingressRule, servicePorts]);
useEffect(() => {
if (namespace.length > 0) {
validate(
ingressRule,
ingressNames || [],
servicesOptions || [],
existingIngressClass
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ingressRule,
namespace,
ingressNames,
servicesOptions,
existingIngressClass,
]);
return (
<>
<PageHeader
title={isEdit ? 'Edit ingress' : 'Add ingress'}
breadcrumbs={[
{
link: 'kubernetes.ingresses',
label: 'Ingresses',
},
{
label: isEdit ? 'Edit ingress' : 'Add ingress',
},
]}
/>
<div className="row ingress-rules">
<div className="col-sm-12">
<IngressForm
environmentID={environmentId}
isLoading={isLoading}
isEdit={isEdit}
rule={ingressRule}
ingressClassOptions={ingressClassOptions}
errors={errors}
servicePorts={servicePorts}
tlsOptions={tlsOptions}
serviceOptions={serviceOptions}
addNewIngressHost={addNewIngressHost}
handleTLSChange={handleTLSChange}
handleHostChange={handleHostChange}
handleIngressChange={handleIngressChange}
handlePathChange={handlePathChange}
addNewIngressRoute={addNewIngressRoute}
removeIngressHost={removeIngressHost}
removeIngressRoute={removeIngressRoute}
addNewAnnotation={addNewAnnotation}
removeAnnotation={removeAnnotation}
reloadTLSCerts={reloadTLSCerts}
handleAnnotationChange={handleAnnotationChange}
namespace={namespace}
handleNamespaceChange={handleNamespaceChange}
namespacesOptions={namespacesOptions}
/>
</div>
{namespace && !isLoading && (
<div className="col-sm-12">
<Button
onClick={() => handleCreateIngressRules()}
disabled={Object.keys(errors).length > 0}
>
{isEdit ? 'Update' : 'Create'}
</Button>
</div>
)}
</div>
</>
);
function validate(
ingressRule: Rule,
ingressNames: string[],
serviceOptions: Option<string>[],
existingIngressClass?: IngressController
) {
const errors: Record<string, ReactNode> = {};
const rule = { ...ingressRule };
// User cannot edit the namespace and the ingress name
if (!isEdit) {
if (!rule.Namespace) {
errors.namespace = 'Namespace is required';
}
const nameRegex = /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/;
if (!rule.IngressName) {
errors.ingressName = 'Ingress name is required';
} else if (!nameRegex.test(rule.IngressName)) {
errors.ingressName =
"This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').";
} else if (ingressNames.includes(rule.IngressName)) {
errors.ingressName = 'Ingress name already exists';
}
if (!rule.IngressClassName) {
errors.className = 'Ingress class is required';
}
}
if (isEdit && !ingressRule.IngressClassName) {
errors.className =
'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.';
}
if (
isEdit &&
(!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName
) {
if (!rule.IngressType) {
errors.className =
'Currently set to an ingress class that cannot be found in the cluster - you must select a valid class.';
} else {
errors.className =
'Currently set to an ingress class that you do not have access to - you must select a valid class.';
}
}
const duplicatedAnnotations: string[] = [];
rule.Annotations?.forEach((a, i) => {
if (!a.Key) {
errors[`annotations.key[${i}]`] = 'Annotation key is required';
} else if (duplicatedAnnotations.includes(a.Key)) {
errors[`annotations.key[${i}]`] = 'Annotation cannot be duplicated';
}
if (!a.Value) {
errors[`annotations.value[${i}]`] = 'Annotation value is required';
}
duplicatedAnnotations.push(a.Key);
});
const duplicatedHosts: string[] = [];
// Check if the paths are duplicates
rule.Hosts?.forEach((host, hi) => {
if (!host.NoHost) {
if (!host.Host) {
errors[`hosts[${hi}].host`] = 'Host is required';
} else if (duplicatedHosts.includes(host.Host)) {
errors[`hosts[${hi}].host`] = 'Host cannot be duplicated';
}
duplicatedHosts.push(host.Host);
}
// Validate service
host.Paths?.forEach((path, pi) => {
if (!path.ServiceName) {
errors[`hosts[${hi}].paths[${pi}].servicename`] =
'Service name is required';
}
if (
isEdit &&
path.ServiceName &&
!serviceOptions.find((s) => s.value === path.ServiceName)
) {
errors[`hosts[${hi}].paths[${pi}].servicename`] = (
<span>
Currently set to {path.ServiceName}, which does not exist. You can
create a service with this name for a particular deployment via{' '}
<Link
to="kubernetes.applications"
params={{ id: environmentId }}
className="text-primary"
target="_blank"
>
Applications
</Link>
, and on returning here it will be picked up.
</span>
);
}
if (!path.ServicePort) {
errors[`hosts[${hi}].paths[${pi}].serviceport`] =
'Service port is required';
}
});
// Validate paths
const paths = host.Paths.map((path) => path.Route);
paths.forEach((item, idx) => {
if (!item) {
errors[`hosts[${hi}].paths[${idx}].path`] = 'Path cannot be empty';
} else if (paths.indexOf(item) !== idx) {
errors[`hosts[${hi}].paths[${idx}].path`] =
'Paths cannot be duplicated';
} else {
// Validate host and path combination globally
const isExists = checkIfPathExistsWithHost(
ingresses,
host.Host,
item,
params.name
);
if (isExists) {
errors[`hosts[${hi}].paths[${idx}].path`] =
'Path is already in use with the same host';
}
}
});
});
setErrors(errors);
if (Object.keys(errors).length > 0) {
return false;
}
return true;
}
function handleNamespaceChange(ns: string) {
setNamespace(ns);
if (!isEdit) {
addNewIngress(ns);
}
}
function handleIngressChange(key: string, val: string) {
setIngressRule((prevRules) => {
const rule = { ...prevRules, [key]: val };
if (key === 'IngressClassName') {
rule.IngressType = ingressControllersResults.data?.find(
(c) => c.ClassName === val
)?.Type;
}
return rule;
});
}
function handleTLSChange(hostIndex: number, tls: string) {
setIngressRule((prevRules) => {
const rule = { ...prevRules };
rule.Hosts[hostIndex] = { ...rule.Hosts[hostIndex], Secret: tls };
return rule;
});
}
function handleHostChange(hostIndex: number, val: string) {
setIngressRule((prevRules) => {
const rule = { ...prevRules };
rule.Hosts[hostIndex] = { ...rule.Hosts[hostIndex], Host: val };
rule.Hosts[hostIndex].Secret =
hostWithTLS[val] || rule.Hosts[hostIndex].Secret;
return rule;
});
}
function handlePathChange(
hostIndex: number,
pathIndex: number,
key: 'Route' | 'PathType' | 'ServiceName' | 'ServicePort',
val: string
) {
setIngressRule((prevRules) => {
const rule = { ...prevRules };
const h = { ...rule.Hosts[hostIndex] };
h.Paths[pathIndex] = {
...h.Paths[pathIndex],
[key]: key === 'ServicePort' ? Number(val) : val,
};
// set the first port of the service as the default port
if (
key === 'ServiceName' &&
servicePorts[val] &&
servicePorts[val].length > 0
) {
h.Paths[pathIndex].ServicePort = Number(servicePorts[val][0].value);
}
rule.Hosts[hostIndex] = h;
return rule;
});
}
function handleAnnotationChange(
index: number,
key: 'Key' | 'Value',
val: string
) {
setIngressRule((prevRules) => {
const rules = { ...prevRules };
rules.Annotations = rules.Annotations || [];
rules.Annotations[index] = rules.Annotations[index] || {
Key: '',
Value: '',
};
rules.Annotations[index][key] = val;
return rules;
});
}
function addNewIngress(namespace: string) {
const newKey = `${namespace}-ingress-${
(ruleCounterByNamespace[namespace] || 0) + 1
}`;
const path: Path = {
Key: uuidv4(),
ServiceName: '',
ServicePort: 0,
Route: '',
PathType: 'Prefix',
};
const host: Host = {
Host: '',
Secret: '',
Paths: [path],
Key: uuidv4(),
};
const rule: Rule = {
Key: uuidv4(),
Namespace: namespace,
IngressName: newKey,
IngressClassName: '',
Hosts: [host],
};
setIngressRule(rule);
}
function addNewIngressHost(noHost = false) {
const rule = { ...ingressRule };
const path: Path = {
ServiceName: '',
ServicePort: 0,
Route: '',
PathType: 'Prefix',
Key: uuidv4(),
};
const host: Host = {
Host: '',
Secret: '',
Paths: [path],
NoHost: noHost,
Key: uuidv4(),
};
rule.Hosts.push(host);
setIngressRule(rule);
}
function addNewIngressRoute(hostIndex: number) {
const rule = { ...ingressRule };
const path: Path = {
ServiceName: '',
ServicePort: 0,
Route: '',
PathType: 'Prefix',
Key: uuidv4(),
};
rule.Hosts[hostIndex].Paths.push(path);
setIngressRule(rule);
}
function addNewAnnotation(type?: 'rewrite' | 'regex' | 'ingressClass') {
const rule = { ...ingressRule };
const annotation: Annotation = {
Key: '',
Value: '',
ID: uuidv4(),
};
switch (type) {
case 'rewrite':
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
annotation.Value = '/$1';
break;
case 'regex':
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
annotation.Value = 'true';
break;
case 'ingressClass':
annotation.Key = 'kubernetes.io/ingress.class';
annotation.Value = '';
break;
default:
break;
}
rule.Annotations = rule.Annotations || [];
rule.Annotations?.push(annotation);
setIngressRule(rule);
}
function removeAnnotation(index: number) {
const rule = { ...ingressRule };
if (index > -1) {
rule.Annotations?.splice(index, 1);
}
setIngressRule(rule);
}
function removeIngressRoute(hostIndex: number, pathIndex: number) {
const rule = { ...ingressRule, Hosts: [...ingressRule.Hosts] };
if (hostIndex > -1 && pathIndex > -1) {
rule.Hosts[hostIndex].Paths.splice(pathIndex, 1);
}
setIngressRule(rule);
}
function removeIngressHost(hostIndex: number) {
const rule = { ...ingressRule, Hosts: [...ingressRule.Hosts] };
if (hostIndex > -1) {
rule.Hosts.splice(hostIndex, 1);
}
setIngressRule(rule);
}
function reloadTLSCerts() {
configResults.refetch();
}
function handleCreateIngressRules() {
const rule = { ...ingressRule };
const classNameToSend =
rule.IngressClassName === 'none' ? '' : rule.IngressClassName;
const ingress: Ingress = {
Namespace: namespace,
Name: rule.IngressName,
ClassName: classNameToSend,
Hosts: rule.Hosts.map((host) => host.Host),
Paths: preparePaths(rule.IngressName, rule.Hosts),
TLS: prepareTLS(rule.Hosts),
Annotations: prepareAnnotations(rule.Annotations || []),
};
if (isEdit) {
updateIngressMutation.mutate(
{ environmentId, ingress },
{
onSuccess: () => {
notifySuccess('Success', 'Ingress updated successfully');
router.stateService.go('kubernetes.ingresses');
},
}
);
} else {
createIngressMutation.mutate(
{ environmentId, ingress },
{
onSuccess: () => {
notifySuccess('Success', 'Ingress created successfully');
router.stateService.go('kubernetes.ingresses');
},
}
);
}
}
}

View file

@ -1,605 +0,0 @@
import { ChangeEvent, ReactNode } from 'react';
import { Plus, RefreshCw, Trash2 } from 'react-feather';
import { Annotations } from '@/kubernetes/react/views/networks/ingresses/components/annotations';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
import { Select, Option } from '@@/form-components/Input/Select';
import { FormError } from '@@/form-components/FormError';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons';
import { Rule, ServicePorts } from './types';
import '../style.css';
const PathTypes: Record<string, string[]> = {
nginx: ['ImplementationSpecific', 'Prefix', 'Exact'],
traefik: ['Prefix', 'Exact'],
other: ['Prefix', 'Exact'],
};
const PlaceholderAnnotations: Record<string, string[]> = {
nginx: ['e.g. nginx.ingress.kubernetes.io/rewrite-target', '/$1'],
traefik: ['e.g. traefik.ingress.kubernetes.io/router.tls', 'true'],
other: ['e.g. app.kubernetes.io/name', 'examplename'],
};
interface Props {
environmentID: number;
rule: Rule;
errors: Record<string, ReactNode>;
isLoading: boolean;
isEdit: boolean;
namespace: string;
servicePorts: ServicePorts;
ingressClassOptions: Option<string>[];
serviceOptions: Option<string>[];
tlsOptions: Option<string>[];
namespacesOptions: Option<string>[];
removeIngressRoute: (hostIndex: number, pathIndex: number) => void;
removeIngressHost: (hostIndex: number) => void;
removeAnnotation: (index: number) => void;
addNewIngressHost: (noHost?: boolean) => void;
addNewIngressRoute: (hostIndex: number) => void;
addNewAnnotation: (type?: 'rewrite' | 'regex' | 'ingressClass') => void;
handleNamespaceChange: (val: string) => void;
handleHostChange: (hostIndex: number, val: string) => void;
handleTLSChange: (hostIndex: number, tls: string) => void;
handleIngressChange: (
key: 'IngressName' | 'IngressClassName',
value: string
) => void;
handleAnnotationChange: (
index: number,
key: 'Key' | 'Value',
val: string
) => void;
handlePathChange: (
hostIndex: number,
pathIndex: number,
key: 'Route' | 'PathType' | 'ServiceName' | 'ServicePort',
val: string
) => void;
reloadTLSCerts: () => void;
}
export function IngressForm({
environmentID,
rule,
isLoading,
isEdit,
servicePorts,
tlsOptions,
handleTLSChange,
addNewIngressHost,
serviceOptions,
handleHostChange,
handleIngressChange,
handlePathChange,
addNewIngressRoute,
removeIngressRoute,
removeIngressHost,
addNewAnnotation,
removeAnnotation,
reloadTLSCerts,
handleAnnotationChange,
ingressClassOptions,
errors,
namespacesOptions,
handleNamespaceChange,
namespace,
}: Props) {
if (isLoading) {
return <div>Loading...</div>;
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'] ||
PlaceholderAnnotations.other;
const pathTypes = PathTypes[rule.IngressType || 'other'] || PathTypes.other;
return (
<Widget>
<WidgetTitle icon="svg-route" title="Ingress" />
<WidgetBody key={rule.Key + rule.Namespace}>
<div className="row">
<div className="form-horizontal">
<div className="form-group">
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="namespace"
>
Namespace
</label>
<div className="col-sm-4">
{isEdit ? (
namespace
) : (
<Select
name="namespaces"
options={namespacesOptions || []}
onChange={(e) => handleNamespaceChange(e.target.value)}
defaultValue={namespace}
disabled={isEdit}
/>
)}
</div>
</div>
</div>
</div>
{namespace && (
<div className="row">
<div className="form-horizontal">
<div className="form-group">
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="ingress_name"
>
Ingress name
</label>
<div className="col-sm-4">
{isEdit ? (
rule.IngressName
) : (
<input
name="ingress_name"
type="text"
className="form-control"
placeholder="Ingress name"
defaultValue={rule.IngressName}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleIngressChange('IngressName', e.target.value)
}
disabled={isEdit}
/>
)}
{errors.ingressName && !isEdit && (
<FormError className="mt-1 error-inline">
{errors.ingressName}
</FormError>
)}
</div>
</div>
<div className="form-group" key={ingressClassOptions.toString()}>
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="ingress_class"
>
Ingress class
</label>
<div className="col-sm-4">
<Select
name="ingress_class"
className="form-control"
placeholder="Ingress name"
defaultValue={rule.IngressClassName}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleIngressChange('IngressClassName', e.target.value)
}
options={ingressClassOptions}
/>
{errors.className && (
<FormError className="mt-1 error-inline">
{errors.className}
</FormError>
)}
</div>
</div>
</div>
<div className="col-sm-12 px-0 text-muted !mb-0">
<div className="mb-2">Annotations</div>
<p className="vertical-center text-muted small">
<Icon icon="info" mode="primary" feather />
<span>
You can specify{' '}
<a
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/"
target="_black"
>
annotations
</a>{' '}
for the object. See further Kubernetes documentation on{' '}
<a
href="https://kubernetes.io/docs/reference/labels-annotations-taints/"
target="_black"
>
well-known annotations
</a>
.
</span>
</p>
</div>
{rule?.Annotations && (
<Annotations
placeholder={placeholderAnnotation}
annotations={rule.Annotations}
handleAnnotationChange={handleAnnotationChange}
removeAnnotation={removeAnnotation}
errors={errors}
/>
)}
<div className="col-sm-12 p-0 anntation-actions">
<Button
className="btn btn-sm btn-light mb-2 !ml-0"
onClick={() => addNewAnnotation()}
icon={Plus}
title="Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type."
>
{' '}
add annotation
</Button>
{rule.IngressType === 'nginx' && (
<>
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('rewrite')}
icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
data-cy="add-rewrite-annotation"
>
{' '}
Add rewrite annotation
</Button>
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('regex')}
icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
data-cy="add-regex-annotation"
>
Add regular expression annotation
</Button>
</>
)}
{rule.IngressType === 'custom' && (
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('ingressClass')}
icon={Plus}
data-cy="add-ingress-class-annotation"
>
Add kubernetes.io/ingress.class annotation
</Button>
)}
</div>
<div className="col-sm-12 px-0 text-muted">Rules</div>
</div>
)}
{namespace &&
rule?.Hosts?.map((host, hostIndex) => (
<div className="row mb-5 rule bordered" key={host.Key}>
<div className="col-sm-12">
<div className="row mt-5 rule-actions">
<div className="col-sm-3 p-0">
{!host.NoHost ? 'Rule' : 'Fallback rule'}
</div>
<div className="col-sm-9 p-0 text-right">
{!host.NoHost && (
<Button
className="btn btn-light btn-sm"
onClick={() => reloadTLSCerts()}
icon={RefreshCw}
>
Reload TLS secrets
</Button>
)}
<Button
className="btn btn-sm ml-2"
color="dangerlight"
type="button"
data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`}
onClick={() => removeIngressHost(hostIndex)}
disabled={rule.Hosts.length === 1}
icon={Trash2}
>
Remove rule
</Button>
</div>
</div>
{!host.NoHost && (
<div className="row">
<div className="form-group !pl-0 col-sm-6 col-lg-4 !pr-2">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Hostname
</span>
<input
name={`ingress_host_${hostIndex}`}
type="text"
className="form-control form-control-sm"
placeholder="e.g. example.com"
defaultValue={host.Host}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleHostChange(hostIndex, e.target.value)
}
/>
</div>
{errors[`hosts[${hostIndex}].host`] && (
<FormError className="mt-1 !mb-0">
{errors[`hosts[${hostIndex}].host`]}
</FormError>
)}
</div>
<div className="form-group !pr-0 col-sm-6 col-lg-4 !pl-2">
<div className="input-group input-group-sm">
<span className="input-group-addon">TLS secret</span>
<Select
key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`}
options={tlsOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleTLSChange(hostIndex, e.target.value)
}
defaultValue={host.Secret}
/>
</div>
</div>
<p className="vertical-center text-muted small whitespace-nowrap col-sm-12 !p-0">
<Icon icon="info" mode="primary" size="md" feather />
<span>
Add a secret via{' '}
<Link
to="kubernetes.configurations"
params={{ id: environmentID }}
className="text-primary"
target="_blank"
>
ConfigMaps &amp; Secrets
</Link>
{', '}
then select &apos;Reload TLS secrets&apos; above to
populate the dropdown with your changes.
</span>
</p>
</div>
)}
{host.NoHost && (
<p className="vertical-center text-muted small whitespace-nowrap col-sm-12 !p-0">
<Icon icon="info" mode="primary" size="md" feather />A
fallback rule has no host specified. This rule only applies
when an inbound request has a hostname that does not match
with any of your other rules.
</p>
)}
<div className="row">
<div className="col-sm-12 px-0 !mb-0 mt-2 text-muted">
Paths
</div>
</div>
{host.Paths.map((path, pathIndex) => (
<div
className="mt-5 !mb-5 row path"
key={`path_${path.Key}}`}
>
<div className="form-group !pl-0 col-sm-3 col-xl-2 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Service
</span>
<Select
key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'ServiceName',
e.target.value
)
}
defaultValue={path.ServiceName}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
] && (
<FormError className="mt-1 !mb-0 error-inline">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
]
}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-2 col-xl-2 !m-0">
{servicePorts && (
<>
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Service port
</span>
<Select
key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={
path.ServiceName &&
servicePorts[path.ServiceName]
? servicePorts[path.ServiceName]
: [
{
label: 'Select port',
value: '',
},
]
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'ServicePort',
e.target.value
)
}
defaultValue={path.ServicePort}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
] && (
<FormError className="mt-1 !mb-0">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
]
}
</FormError>
)}
</>
)}
</div>
<div className="form-group !pl-0 col-sm-3 col-xl-2 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Path type
</span>
<Select
key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={
pathTypes
? pathTypes.map((type) => ({
label: type,
value: type,
}))
: []
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'PathType',
e.target.value
)
}
defaultValue={path.PathType}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
] && (
<FormError className="mt-1 !mb-0">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
]
}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-3 col-xl-3 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Path</span>
<input
className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example"
data-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
data-cy={`k8sAppCreate-route_${hostIndex}-${pathIndex}`}
defaultValue={path.Route}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'Route',
e.target.value
)
}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].path`
] && (
<FormError className="mt-1 !mb-0">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].path`
]
}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-1 !m-0">
<Button
className="btn btn-sm btn-only-icon !ml-0 vertical-center"
color="dangerlight"
type="button"
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
onClick={() => removeIngressRoute(hostIndex, pathIndex)}
disabled={host.Paths.length === 1}
icon={Trash2}
/>
</div>
</div>
))}
<div className="row mt-5">
<Button
className="btn btn-sm btn-light !ml-0"
type="button"
onClick={() => addNewIngressRoute(hostIndex)}
icon={Plus}
>
Add path
</Button>
</div>
</div>
</div>
))}
{namespace && (
<div className="row p-0 rules-action">
<div className="col-sm-12 p-0 vertical-center">
<Button
className="btn btn-sm btn-light !ml-0"
type="button"
onClick={() => addNewIngressHost()}
icon={Plus}
>
Add new host
</Button>
<Button
className="btn btn-sm btn-light ml-2"
type="button"
onClick={() => addNewIngressHost(true)}
disabled={hasNoHostRule}
icon={Plus}
>
Add fallback rule
</Button>
<Tooltip message="A fallback rule will be applied to all requests that do not match any of the defined hosts." />
</div>
</div>
)}
</WidgetBody>
</Widget>
);
}

View file

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

View file

@ -1,33 +0,0 @@
import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types';
import { Option } from '@@/form-components/Input/Select';
export interface Path {
Key: string;
Route: string;
ServiceName: string;
ServicePort: number;
PathType?: string;
}
export interface Host {
Key: string;
Host: string;
Secret: string;
Paths: Path[];
NoHost?: boolean;
}
export interface Rule {
Key: string;
IngressName: string;
Namespace: string;
IngressClassName: string;
Hosts: Host[];
Annotations?: Annotation[];
IngressType?: string;
}
export interface ServicePorts {
[serviceName: string]: Option<string>[];
}

View file

@ -1,136 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
import { TLS, Ingress } from '../types';
import { Host, Rule } from './types';
const ignoreAnnotationsForEdit = [
'kubectl.kubernetes.io/last-applied-configuration',
];
export function prepareTLS(hosts: Host[]) {
const tls: TLS[] = [];
hosts.forEach((host) => {
if (host.Secret && host.Host) {
tls.push({
Hosts: [host.Host],
SecretName: host.Secret,
});
}
});
return tls;
}
export function preparePaths(ingressName: string, hosts: Host[]) {
return hosts.flatMap((host) =>
host.Paths.map((p) => ({
ServiceName: p.ServiceName,
Host: host.Host,
Path: p.Route,
Port: p.ServicePort,
PathType: p.PathType || 'Prefix',
IngressName: ingressName,
}))
);
}
export function prepareAnnotations(annotations: Annotation[]) {
const result: Record<string, string> = {};
annotations.forEach((a) => {
result[a.Key] = a.Value;
});
return result;
}
function getSecretByHost(host: string, tls?: TLS[]) {
let secret = '';
if (tls) {
tls.forEach((t) => {
if (t.Hosts.indexOf(host) !== -1) {
secret = t.SecretName;
}
});
}
return secret;
}
export function prepareRuleHostsFromIngress(ing: Ingress) {
const hosts = ing.Hosts?.map((host) => {
const h: Host = {} as Host;
h.Host = host;
h.Secret = getSecretByHost(host, ing.TLS);
h.Paths = [];
ing.Paths?.forEach((path) => {
if (path.Host === host) {
h.Paths.push({
Route: path.Path,
ServiceName: path.ServiceName,
ServicePort: path.Port,
PathType: path.PathType,
Key: Math.random().toString(),
});
}
});
if (!host) {
h.NoHost = true;
}
h.Key = uuidv4();
return h;
});
return hosts;
}
export function getAnnotationsForEdit(
annotations: Record<string, string>
): Annotation[] {
const result: Annotation[] = [];
Object.keys(annotations).forEach((k) => {
if (ignoreAnnotationsForEdit.indexOf(k) === -1) {
result.push({
Key: k,
Value: annotations[k],
ID: uuidv4(),
});
}
});
return result;
}
export function prepareRuleFromIngress(
ing: Ingress,
type?: SupportedIngControllerTypes
): Rule {
return {
Key: uuidv4(),
IngressName: ing.Name,
Namespace: ing.Namespace,
IngressClassName: type === 'custom' ? 'none' : ing.ClassName,
Hosts: prepareRuleHostsFromIngress(ing) || [],
Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [],
IngressType: ing.Type,
};
}
export function checkIfPathExistsWithHost(
ingresses: Ingress[],
host: string,
path: string,
ingressName?: string
) {
let exists = false;
ingresses.forEach((ingress) => {
if (ingressName && ingress.Name === ingressName) {
return;
}
ingress.Paths?.forEach((p) => {
if (p.Host === host && p.Path === path) {
exists = true;
}
});
});
return exists;
}

View file

@ -1,134 +0,0 @@
import { Plus, Trash2 } from 'react-feather';
import { useRouter } from '@uirouter/react';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { DeleteIngressesRequest, Ingress } from '../types';
import { useDeleteIngresses, useIngresses } from '../queries';
import { createStore } from './datatable-store';
import { useColumns } from './columns';
import '../style.css';
interface SelectedIngress {
Namespace: string;
Name: string;
}
const useStore = createStore('ingresses');
export function IngressDataTable() {
const environmentId = useEnvironmentId();
const nsResult = useNamespaces(environmentId);
const ingressesQuery = useIngresses(
environmentId,
Object.keys(nsResult?.data || {})
);
const settings = useStore();
const columns = useColumns();
const deleteIngressesMutation = useDeleteIngresses();
const router = useRouter();
return (
<Datatable
dataset={ingressesQuery.data || []}
storageKey="ingressClassesNameSpace"
columns={columns}
settingsStore={settings}
isLoading={ingressesQuery.isLoading}
emptyContentLabel="No supported ingresses found"
titleOptions={{
icon: 'svg-route',
title: 'Ingresses',
}}
getRowId={(row) => row.Name + row.Type + row.Namespace}
renderTableActions={tableActions}
disableSelect={useCheckboxes()}
/>
);
function tableActions(selectedFlatRows: Ingress[]) {
return (
<div className="ingressDatatable-actions">
<Authorized authorizations="AzureContainerGroupDelete">
<Button
className="btn-wrapper"
color="dangerlight"
disabled={selectedFlatRows.length === 0}
onClick={() =>
handleRemoveClick(
selectedFlatRows.map((row) => ({
Name: row.Name,
Namespace: row.Namespace,
}))
)
}
icon={Trash2}
>
Remove
</Button>
</Authorized>
<Authorized authorizations="K8sIngressesW">
<Link to="kubernetes.ingresses.create" className="space-left">
<Button
icon={Plus}
className="btn-wrapper vertical-center"
color="secondary"
>
Add with form
</Button>
</Link>
</Authorized>
<Authorized authorizations="K8sIngressesW">
<Link to="kubernetes.deploy" className="space-left">
<Button icon={Plus} className="btn-wrapper">
Create from manifest
</Button>
</Link>
</Authorized>
</div>
);
}
function useCheckboxes() {
return !useAuthorizations(['K8sIngressesW']);
}
async function handleRemoveClick(ingresses: SelectedIngress[]) {
const confirmed = await confirmDeletionAsync(
'Are you sure you want to delete the selected ingresses?'
);
if (!confirmed) {
return null;
}
const payload: DeleteIngressesRequest = {} as DeleteIngressesRequest;
ingresses.forEach((ingress) => {
payload[ingress.Namespace] = payload[ingress.Namespace] || [];
payload[ingress.Namespace].push(ingress.Name);
});
deleteIngressesMutation.mutate(
{ environmentId, data: payload },
{
onSuccess: () => {
router.stateService.reload();
},
}
);
return ingresses;
}
}

View file

@ -1,11 +0,0 @@
import { Column } from 'react-table';
import { Ingress } from '../../types';
export const className: Column<Ingress> = {
Header: 'Class Name',
accessor: 'ClassName',
id: 'className',
disableFilters: true,
canHide: true,
};

View file

@ -1,11 +0,0 @@
import { useMemo } from 'react';
import { name } from './name';
import { type } from './type';
import { namespace } from './namespace';
import { className } from './className';
import { ingressRules } from './ingressRules';
export function useColumns() {
return useMemo(() => [name, namespace, className, type, ingressRules], []);
}

View file

@ -1,55 +0,0 @@
import { CellProps, Column } from 'react-table';
import { Icon } from '@@/Icon';
import { Badge } from '@@/Badge';
import { Ingress, TLS, Path } from '../../types';
function isHTTP(TLSs: TLS[], host: string) {
return TLSs.filter((t) => t.Hosts.indexOf(host) !== -1).length === 0;
}
function link(host: string, path: string, isHttp: boolean) {
if (!host) {
return path;
}
return (
<a
href={`${isHttp ? 'http' : 'https'}://${host}${path}`}
target="_blank"
rel="noreferrer"
>
{`${isHttp ? 'http' : 'https'}://${host}${path}`}
</a>
);
}
export const ingressRules: Column<Ingress> = {
Header: 'Rules and Paths',
accessor: 'Paths',
Cell: ({ row }: CellProps<Ingress, Path[]>) => {
const results = row.original.Paths?.map((path: Path) => {
const isHttp = isHTTP(row.original.TLS || [], path.Host);
return (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
<span className="flex px-2 flex-nowrap items-center gap-1">
{link(path.Host, path.Path, isHttp)}
<Icon icon="arrow-right" feather />
{`${path.ServiceName}:${path.Port}`}
{!path.HasService && (
<Badge type="warn" className="ml-1 gap-1">
<Icon icon="alert-triangle" feather />
Service doesn&apos;t exist
</Badge>
)}
</span>
</div>
);
});
return results || <div />;
},
id: 'ingressRules',
disableFilters: true,
canHide: true,
disableSortBy: true,
};

View file

@ -1,33 +0,0 @@
import { CellProps, Column } from 'react-table';
import { Authorized } from '@/portainer/hooks/useUser';
import { Link } from '@@/Link';
import { Ingress } from '../../types';
export const name: Column<Ingress> = {
Header: 'Name',
accessor: 'Name',
Cell: ({ row }: CellProps<Ingress>) => (
<Authorized
authorizations="K8sIngressesW"
childrenUnauthorized={row.original.Name}
>
<Link
to="kubernetes.ingresses.edit"
params={{
uid: row.original.UID,
namespace: row.original.Namespace,
name: row.original.Name,
}}
title={row.original.Name}
>
{row.original.Name}
</Link>
</Authorized>
),
id: 'name',
disableFilters: true,
canHide: true,
};

View file

@ -1,33 +0,0 @@
import { CellProps, Column, Row } from 'react-table';
import { filterHOC } from '@/react/components/datatables/Filter';
import { Link } from '@@/Link';
import { Ingress } from '../../types';
export const namespace: Column<Ingress> = {
Header: 'Namespace',
accessor: 'Namespace',
Cell: ({ row }: CellProps<Ingress>) => (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: row.original.Namespace,
}}
title={row.original.Namespace}
>
{row.original.Namespace}
</Link>
),
id: 'namespace',
disableFilters: false,
canHide: true,
Filter: filterHOC('Filter by namespace'),
filter: (rows: Row<Ingress>[], filterValue, filters) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((r) => filters.includes(r.original.Namespace));
},
};

View file

@ -1,11 +0,0 @@
import { Column } from 'react-table';
import { Ingress } from '../../types';
export const type: Column<Ingress> = {
Header: 'Type',
accessor: 'Type',
id: 'type',
disableFilters: true,
canHide: true,
};

View file

@ -1,26 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from '../types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -1,20 +0,0 @@
import { PageHeader } from '@@/PageHeader';
import { IngressDataTable } from './IngressDataTable';
export function IngressesDatatableView() {
return (
<>
<PageHeader
title="Ingresses"
breadcrumbs={[
{
label: 'Ingresses',
},
]}
reload
/>
<IngressDataTable />
</>
);
}

View file

@ -1,84 +0,0 @@
import { ChangeEvent, ReactNode } from 'react';
import { Icon } from '@@/Icon';
import { FormError } from '@@/form-components/FormError';
import { Annotation } from './types';
interface Props {
annotations: Annotation[];
handleAnnotationChange: (
index: number,
key: 'Key' | 'Value',
val: string
) => void;
removeAnnotation: (index: number) => void;
errors: Record<string, ReactNode>;
placeholder: string[];
}
export function Annotations({
annotations,
handleAnnotationChange,
removeAnnotation,
errors,
placeholder,
}: Props) {
return (
<>
{annotations.map((annotation, i) => (
<div className="row" key={annotation.ID}>
<div className="form-group !pl-0 col-sm-4 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Key</span>
<input
name={`annotation_key_${i}`}
type="text"
className="form-control form-control-sm"
placeholder={placeholder[0]}
defaultValue={annotation.Key}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleAnnotationChange(i, 'Key', e.target.value)
}
/>
</div>
{errors[`annotations.key[${i}]`] && (
<FormError className="mt-1 !mb-0">
{errors[`annotations.key[${i}]`]}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-4 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Value</span>
<input
name={`annotation_value_${i}`}
type="text"
className="form-control form-control-sm"
placeholder={placeholder[1]}
defaultValue={annotation.Value}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleAnnotationChange(i, 'Value', e.target.value)
}
/>
</div>
{errors[`annotations.value[${i}]`] && (
<FormError className="mt-1 !mb-0">
{errors[`annotations.value[${i}]`]}
</FormError>
)}
</div>
<div className="col-sm-3 !pl-0 !m-0">
<button
className="btn btn-sm btn-dangerlight btn-only-icon !ml-0"
type="button"
onClick={() => removeAnnotation(i)}
>
<Icon icon="trash-2" size="md" feather />
</button>
</div>
</div>
))}
</>
);
}

View file

@ -1,5 +0,0 @@
export interface Annotation {
Key: string;
Value: string;
ID: string;
}

View file

@ -1,202 +0,0 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { getServices } from '../services/service';
import {
getIngresses,
getIngress,
createIngress,
deleteIngresses,
updateIngress,
getIngressControllers,
} from './service';
import { DeleteIngressesRequest, Ingress } from './types';
const ingressKeys = {
all: ['environments', 'kubernetes', 'namespace', 'ingress'] as const,
namespace: (
environmentId: EnvironmentId,
namespace: string,
ingress: string
) => [...ingressKeys.all, String(environmentId), namespace, ingress] as const,
};
export function useIngress(
environmentId: EnvironmentId,
namespace: string,
name: string
) {
return useQuery(
[
'environments',
environmentId,
'kubernetes',
'namespace',
namespace,
'ingress',
name,
],
async () => {
const ing = await getIngress(environmentId, namespace, name);
return ing;
},
{
...withError('Unable to get ingress'),
}
);
}
export function useIngresses(
environmentId: EnvironmentId,
namespaces: string[]
) {
return useQuery(
[
'environments',
environmentId,
'kubernetes',
'namespace',
namespaces,
'ingress',
],
async () => {
const settledIngressesPromise = await Promise.allSettled(
namespaces.map((namespace) => getIngresses(environmentId, namespace))
);
const ingresses = settledIngressesPromise
.filter(isFulfilled)
?.map((i) => i.value);
// flatten the array and remove empty ingresses
const filteredIngresses = ingresses.flat().filter((ing) => ing);
// get all services in only the namespaces that the ingresses are in to find missing services
const uniqueNamespacesWithIngress = [
...new Set(filteredIngresses.map((ing) => ing?.Namespace)),
];
const settledServicesPromise = await Promise.allSettled(
uniqueNamespacesWithIngress.map((ns) => getServices(environmentId, ns))
);
const services = settledServicesPromise
.filter(isFulfilled)
?.map((s) => s.value)
.flat();
// check if each ingress path service has a service that still exists
filteredIngresses.forEach((ing, iIndex) => {
const servicesInNamespace = services?.filter(
(service) => service?.Namespace === ing?.Namespace
);
const serviceNamesInNamespace = servicesInNamespace?.map(
(service) => service.Name
);
ing.Paths?.forEach((path, pIndex) => {
if (
!serviceNamesInNamespace?.includes(path.ServiceName) &&
filteredIngresses[iIndex].Paths
) {
filteredIngresses[iIndex].Paths[pIndex].HasService = false;
} else {
filteredIngresses[iIndex].Paths[pIndex].HasService = true;
}
});
});
return filteredIngresses;
},
{
enabled: namespaces.length > 0,
...withError('Unable to get ingresses'),
}
);
}
export function useCreateIngress() {
const queryClient = useQueryClient();
return useMutation(
({
environmentId,
ingress,
}: {
environmentId: EnvironmentId;
ingress: Ingress;
}) => createIngress(environmentId, ingress),
mutationOptions(
withError('Unable to create ingress controller'),
withInvalidate(queryClient, [ingressKeys.all])
)
);
}
export function useUpdateIngress() {
const queryClient = useQueryClient();
return useMutation(
({
environmentId,
ingress,
}: {
environmentId: EnvironmentId;
ingress: Ingress;
}) => updateIngress(environmentId, ingress),
mutationOptions(
withError('Unable to update ingress controller'),
withInvalidate(queryClient, [ingressKeys.all])
)
);
}
export function useDeleteIngresses() {
const queryClient = useQueryClient();
return useMutation(
({
environmentId,
data,
}: {
environmentId: EnvironmentId;
data: DeleteIngressesRequest;
}) => deleteIngresses(environmentId, data),
mutationOptions(
withError('Unable to update ingress controller'),
withInvalidate(queryClient, [ingressKeys.all])
)
);
}
/**
* Ingress Controllers
*/
export function useIngressControllers(
environmentId: EnvironmentId,
namespace: string
) {
return useQuery(
[
'environments',
environmentId,
'kubernetes',
'namespace',
namespace,
'ingresscontrollers',
],
async () => {
const ing = await getIngressControllers(environmentId, namespace);
return ing;
},
{
enabled: !!namespace,
cacheTime: 0,
...withError('Unable to get ingress controllers'),
}
);
}
function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}

View file

@ -1,100 +0,0 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Ingress, DeleteIngressesRequest, IngressController } from './types';
export async function getIngress(
environmentId: EnvironmentId,
namespace: string,
ingressName: string
) {
try {
const { data: ingress } = await axios.get<Ingress[]>(
buildUrl(environmentId, namespace, ingressName)
);
return ingress[0];
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve the ingress');
}
}
export async function getIngresses(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: ingresses } = await axios.get<Ingress[]>(
buildUrl(environmentId, namespace)
);
return ingresses;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve ingresses');
}
}
export async function getIngressControllers(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: ingresscontrollers } = await axios.get<IngressController[]>(
`kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`
);
return ingresscontrollers;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve ingresses');
}
}
export async function createIngress(
environmentId: EnvironmentId,
ingress: Ingress
) {
try {
return await axios.post(
buildUrl(environmentId, ingress.Namespace),
ingress
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create an ingress');
}
}
export async function updateIngress(
environmentId: EnvironmentId,
ingress: Ingress
) {
try {
return await axios.put(buildUrl(environmentId, ingress.Namespace), ingress);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update an ingress');
}
}
export async function deleteIngresses(
environmentId: EnvironmentId,
data: DeleteIngressesRequest
) {
try {
return await axios.post(
`kubernetes/${environmentId}/ingresses/delete`,
data
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete ingresses');
}
}
function buildUrl(
environmentId: EnvironmentId,
namespace: string,
ingressName?: string
) {
let url = `kubernetes/${environmentId}/namespaces/${namespace}/ingresses`;
if (ingressName) {
url += `/${ingressName}`;
}
return url;
}

View file

@ -1,30 +0,0 @@
.ingress-rules .bordered {
border: 1px solid var(--border-color);
border-radius: 5px;
}
.ingress-rules .rule {
background-color: var(--bg-body-color);
}
.ingressDatatable-actions button > span,
.anntation-actions button > span,
.rules-action button > span,
.rule button > span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.error-inline {
display: block;
}
.error-inline svg {
margin-right: 5px;
}
.error-inline svg,
.error-inline span {
display: inline;
}

View file

@ -1,48 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export interface Path {
IngressName: string;
ServiceName: string;
Host: string;
Port: number;
Path: string;
PathType: string;
HasService?: boolean;
}
export interface TLS {
Hosts: string[];
SecretName: string;
}
export type Ingress = {
Name: string;
UID?: string;
Namespace: string;
ClassName: string;
Annotations?: Record<string, string>;
Hosts?: string[];
Paths: Path[];
TLS?: TLS[];
Type?: string;
};
export interface DeleteIngressesRequest {
[key: string]: string[];
}
export interface IngressController {
Name: string;
ClassName: string;
Availability: string;
Type: SupportedIngControllerTypes;
New: boolean;
}

View file

@ -1,27 +0,0 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { getServices } from './service';
import { Service } from './types';
export function useServices(environmentId: EnvironmentId, namespace: string) {
return useQuery(
[
'environments',
environmentId,
'kubernetes',
'namespaces',
namespace,
'services',
],
() =>
namespace ? getServices(environmentId, namespace) : ([] as Service[]),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get services');
},
}
);
}

View file

@ -1,23 +0,0 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Service } from './types';
export async function getServices(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: services } = await axios.get<Service[]>(
buildUrl(environmentId, namespace)
);
return services;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve services');
}
}
function buildUrl(environmentId: EnvironmentId, namespace: string) {
const url = `kubernetes/${environmentId}/namespaces/${namespace}/services`;
return url;
}

View file

@ -1,33 +0,0 @@
export interface Port {
Name: string;
Protocol: string;
Port: number;
TargetPort: number;
NodePort?: number;
}
export interface IngressIP {
IP: string;
}
export interface LoadBalancer {
Ingress: IngressIP[];
}
export interface Status {
LoadBalancer: LoadBalancer;
}
export interface Service {
Annotations?: Document;
CreationTimestamp?: string;
Labels?: Document;
Name: string;
Namespace: string;
UID: string;
AllocateLoadBalancerNodePorts?: boolean;
Ports: Port[];
Selector?: Document;
Type: string;
Status?: Status;
}