diff --git a/console-extensions.json b/console-extensions.json index f295227..a01658e 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -18,6 +18,17 @@ "component": { "$codeRef": "GatewayCreatePage" } } }, + { + "type": "console.resource/create", + "properties": { + "model": { + "group": "gateway.networking.k8s.io", + "version": "v1", + "kind": "HTTPRoute" + }, + "component": { "$codeRef": "HTTPRouteCreatePage" } + } + }, { "type": "console.tab/horizontalNav", "properties": { @@ -33,6 +44,25 @@ "component": { "$codeRef": "GatewaySingleOverview" } } }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": "/k8s/ns/:namespace/gateway.networking.k8s.io~v1~HTTPRoute/:name/edit", + "component": { "$codeRef": "HTTPRouteCreatePage" } + } + }, + { + "type": "console.action/resource-provider", + "properties": { + "model": { + "group": "gateway.networking.k8s.io", + "version": "v1", + "kind": "HTTPRoute" + }, + "provider": { "$codeRef": "useHTTPRouteActions" } + } + }, { "type": "console.tab/horizontalNav", "properties": { @@ -48,4 +78,4 @@ "component": { "$codeRef": "HTTPRouteSingleOverview" } } } -] \ No newline at end of file +] diff --git a/locales/en/plugin__gateway-api-console-plugin.json b/locales/en/plugin__gateway-api-console-plugin.json index 42c6b94..49f80bb 100644 --- a/locales/en/plugin__gateway-api-console-plugin.json +++ b/locales/en/plugin__gateway-api-console-plugin.json @@ -1,21 +1,33 @@ { "A Gateway represents an instance of a service-traffic handling infrastructure by binding Listeners to a set of IP addresses.": "A Gateway represents an instance of a service-traffic handling infrastructure by binding Listeners to a set of IP addresses.", + "A list of actions to perform on a request or response before it is sent to the backend.": "A list of actions to perform on a request or response before it is sent to the backend.", "A map of key/value pairs to enable implementation-specific TLS options, such as minimum TLS version or cipher suites.": "A map of key/value pairs to enable implementation-specific TLS options, such as minimum TLS version or cipher suites.", "A unique name for the gateway within the namespace.": "A unique name for the gateway within the namespace.", "A unique name for this listener within the gateway.": "A unique name for this listener within the gateway.", "Actions": "Actions", "Add": "Add", "Add address": "Add address", + "Add backend reference": "Add backend reference", "Add certificate reference": "Add certificate reference", + "Add filter": "Add filter", + "Add hostname": "Add hostname", "Add listener": "Add listener", - "Add Listener": "Add Listener", + "Add match": "Add match", + "Add more": "Add more", + "Add parent reference": "Add parent reference", "Add route kind": "Add route kind", + "Add rule": "Add rule", "Add TLS option": "Add TLS option", + "Add, set, or remove request headers.": "Add, set, or remove request headers.", + "Add, set, or remove response headers.": "Add, set, or remove response headers.", "Addresses (Optional)": "Addresses (Optional)", "Addresses help": "Addresses help", "Addresses table": "Addresses table", "All": "All", + "All backend references": "All backend references", + "All filters": "All filters", "All hostnames": "All hostnames", + "All matches": "All matches", "All route kinds": "All route kinds", "Allow from all namespaces.": "Allow from all namespaces.", "Allow from namespaces matching a specific label.": "Allow from namespaces matching a specific label.", @@ -24,6 +36,13 @@ "Allowed Route Kinds": "Allowed Route Kinds", "Allowed Routes": "Allowed Routes", "At least one listener is required to create a Gateway.": "At least one listener is required to create a Gateway.", + "At least one parent reference required for the HTTPRoute": "At least one parent reference required for the HTTPRoute", + "At least one rule is required for a HTTPRoute to handle traffic.": "At least one rule is required for a HTTPRoute to handle traffic.", + "Attaches this route to the selected Gateway(s), linking it to the entry point.": "Attaches this route to the selected Gateway(s), linking it to the entry point.", + "Auto-filled from selected service": "Auto-filled from selected service", + "Backend references": "Backend references", + "Backend References": "Backend References", + "Backend Service": "Backend Service", "Cancel": "Cancel", "Certificate name": "Certificate name", "Certificate namespace": "Certificate namespace", @@ -32,30 +51,57 @@ "Certificate references for TLS termination. Specify the name, namespace, and kind of the certificate resource.": "Certificate references for TLS termination. Specify the name, namespace, and kind of the certificate resource.", "Configuration": "Configuration", "Create": "Create", + "Create an HTTPRoute to route traffic from the Gateway to backend services.": "Create an HTTPRoute to route traffic from the Gateway to backend services.", "Create Gateway": "Create Gateway", + "Create HTTPRoute": "Create HTTPRoute", + "Defines the backend Kubernetes Service(s) to forward traffic to. Traffic is load-balanced between them based on weight.": "Defines the backend Kubernetes Service(s) to forward traffic to. Traffic is load-balanced between them based on weight.", + "Defines the backend Kubernetes Service(s) to forward traffic to. Traffic is load-balanced between them based on weight. If omitted, this rule will have no effect.": "Defines the backend Kubernetes Service(s) to forward traffic to. Traffic is load-balanced between them based on weight. If omitted, this rule will have no effect.", + "Defines the criteria for a request to match this rule. If multiple matches are specified, they are OR ed. If omitted, this rule matches all requests.": "Defines the criteria for a request to match this rule. If multiple matches are specified, they are OR ed. If omitted, this rule matches all requests.", + "Defines the criteria for a request to match this rule. If multiple matches are specified, they are OR ed. If omitted, this rule matches all requests. Multiple Matches in one Rule share all BackendRefs.": "Defines the criteria for a request to match this rule. If multiple matches are specified, they are OR ed. If omitted, this rule matches all requests. Multiple Matches in one Rule share all BackendRefs.", + "Delete": "Delete", + "Delete it permanently": "Delete it permanently", + "Delete match": "Delete match", "e.g., 192.168.1.100": "e.g., 192.168.1.100", "e.g., gateway.example.com": "e.g., gateway.example.com", "e.g., gateway.networking.k8s.io": "e.g., gateway.networking.k8s.io", "e.g., minVersion, cipherSuites": "e.g., minVersion, cipherSuites", "e.g., TLSv1.2, ECDHE-RSA-AES256-GCM-SHA384": "e.g., TLSv1.2, ECDHE-RSA-AES256-GCM-SHA384", + "Edit": "Edit", + "Edit an HTTPRoute to route traffic from the Gateway to backend services.": "Edit an HTTPRoute to route traffic from the Gateway to backend services.", "Edit Gateway": "Edit Gateway", + "Edit HTTPRoute": "Edit HTTPRoute", "Edit listener": "Edit listener", - "Edit Listener": "Edit Listener", + "Edit rule": "Edit rule", "Enter gateway name": "Enter gateway name", "Enter hostname (optional)": "Enter hostname (optional)", "Enter listener name": "Enter listener name", "Enter port (1-65535)": "Enter port (1-65535)", "Envoy Gateway": "Envoy Gateway", "Error: YAML Validation": "Error: YAML Validation", + "example.com": "example.com", + "Filter type": "Filter type", + "Filters": "Filters", "Gateway class cannot be changed after creation.": "Gateway class cannot be changed after creation.", "Gateway Class Name": "Gateway Class Name", - "Gateway Name": "Gateway Name", + "Gateway is terminating.": "Gateway is terminating.", + "Gateway name": "Gateway name", "Gateway names cannot be changed after creation.": "Gateway names cannot be changed after creation.", + "Gateway Namespace": "Gateway Namespace", "Group": "Group", + "Header name": "Header name", + "Header value": "Header value", + "Headers": "Headers", "Hostname": "Hostname", + "Hostnames": "Hostnames", + "Hostnames help": "Hostnames help", + "HTTP method": "HTTP method", + "HTTPRoute name": "HTTPRoute name", + "Inherits from parent listener if empty.": "Inherits from parent listener if empty.", "Istio": "Istio", "Key": "Key", "Kind": "Kind", + "Learn more": "Learn more", + "Listener is not available for route binding.": "Listener is not available for route binding.", "Listener Name": "Listener Name", "Listener Summary": "Listener Summary", "Listeners": "Listeners", @@ -64,7 +110,11 @@ "Listeners table": "Listeners table", "Loading gateway...": "Loading gateway...", "Loading YAML editor...": "Loading YAML editor...", + "Matches": "Matches", + "Matches traffic for these hostnames.": "Matches traffic for these hostnames.", + "Mirror backend name": "Mirror backend name", "Missing required metadata fields (uid, resourceVersion) for {{kind}} update": "Missing required metadata fields (uid, resourceVersion) for {{kind}} update", + "more": "more", "N/A": "N/A", "Name": "Name", "Namespace": "Namespace", @@ -72,31 +122,86 @@ "No attached resources found": "No attached resources found", "No matching resources found": "No matching resources found", "None": "None", + "Not allowed by Gateway settings.": "Not allowed by Gateway settings.", + "Not set": "Not set", "Not specified": "Not specified", + "Only HTTPRoute is supported by this Gateway.": "Only HTTPRoute is supported by this Gateway.", "Optional hostname to match requests. Leave empty to match all hostnames.": "Optional hostname to match requests. Leave empty to match all hostnames.", + "Parent reference": "Parent reference", + "Parent references": "Parent references", + "Parent references help": "Parent references help", + "Path redirect": "Path redirect", + "Path redirect type": "Path redirect type", + "Path replacement type": "Path replacement type", + "Path rewrite": "Path rewrite", + "Path type": "Path type", + "Path value": "Path value", "Port": "Port", "Protocol": "Protocol", + "Query param name": "Query param name", + "Query param value": "Query param value", + "Query Params": "Query Params", + "Redirect path value": "Redirect path value", + "Redirect the request to a different hostname, path, or port.": "Redirect the request to a different hostname, path, or port.", + "Redirect type": "Redirect type", "Remove": "Remove", "Remove address": "Remove address", "Remove listener": "Remove listener", + "Remove parent reference": "Remove parent reference", "Request a specific static IP address or hostname for the Gateway. This is optional and used to specify where the Gateway should be accessible.": "Request a specific static IP address or hostname for the Gateway. This is optional and used to specify where the Gateway should be accessible.", + "Request Header Modifier": "Request Header Modifier", + "Request Mirror": "Request Mirror", + "Request Redirect": "Request Redirect", + "Requests are evaluated against rules in order.": "Requests are evaluated against rules in order.", + "Response Header Modifier": "Response Header Modifier", + "Restart configuration": "Restart configuration", "Restricts the types of Route resources that can attach to this listener (e.g., only HTTPRoute).": "Restricts the types of Route resources that can attach to this listener (e.g., only HTTPRoute).", "Review & Create": "Review & Create", + "Review and create": "Review and create", + "Rewrite the hostname or path of the request before forwarding.": "Rewrite the hostname or path of the request before forwarding.", "Route Kind": "Route Kind", + "Rule ID": "Rule ID", + "Rule incomplete": "Rule incomplete", + "Rules": "Rules", + "Rules are used for matching and processing requests. ": "Rules are used for matching and processing requests. ", + "Rules help": "Rules help", + "Rules table": "Rules table", "Same": "Same", "Save": "Save", + "Scheme": "Scheme", + "Scheme redirect": "Scheme redirect", "Search by {{filterValue}}...": "Search by {{filterValue}}...", + "Section": "Section", + "Section name": "Section name", "Select Address Type": "Select Address Type", "Select Allowed Namespaces": "Select Allowed Namespaces", "Select Certificate Kind": "Select Certificate Kind", + "Select Gateway": "Select Gateway", "Select Gateway Class": "Select Gateway Class", + "Select header action": "Select header action", + "Select header type": "Select header type", + "Select HTTP method": "Select HTTP method", + "Select path type": "Select path type", + "Select Port": "Select Port", "Select Protocol": "Select Protocol", + "Select query param type": "Select query param type", "Select Route Kind": "Select Route Kind", + "Select Section": "Select Section", + "Select Service": "Select Service", "Select TLS Mode": "Select TLS Mode", + "Select type": "Select type", "Selector": "Selector", + "Send a copy of the request to a different backend (for traffic shadowing).": "Send a copy of the request to a different backend (for traffic shadowing).", + "Service Name": "Service Name", + "Set": "Set", + "Show detailed errors": "Show detailed errors", + "Show warnings": "Show warnings", "Some references for the HTTPRoute could not be resolved.": "Some references for the HTTPRoute could not be resolved.", "Specify static IP addresses or hostnames where the Gateway should be accessible. This is optional and depends on your infrastructure setup.": "Specify static IP addresses or hostnames where the Gateway should be accessible. This is optional and depends on your infrastructure setup.", "Status": "Status", + "Status code": "Status code", + "Supports wildcards (e.g., *.example.com).": "Supports wildcards (e.g., *.example.com).", + "The first rule that matches is used.": "The first rule that matches is used.", "The gateway class name must be unique within the namespace and conform to DNS-1123 label standards (lowercase alphanumeric characters or \"-\").": "The gateway class name must be unique within the namespace and conform to DNS-1123 label standards (lowercase alphanumeric characters or \"-\").", "The Gateway configuration is accepted but not yet programmed.": "The Gateway configuration is accepted but not yet programmed.", "The Gateway has issues and is not ready to serve traffic.": "The Gateway has issues and is not ready to serve traffic.", @@ -104,18 +209,26 @@ "The Gateway is accepted, programmed, and ready to serve traffic.": "The Gateway is accepted, programmed, and ready to serve traffic.", "The HTTPRoute is accepted by at least one parent gateway.": "The HTTPRoute is accepted by at least one parent gateway.", "The HTTPRoute is not accepted by any parent gateways.": "The HTTPRoute is not accepted by any parent gateways.", + "The name of the Gateway to attach to.": "The name of the Gateway to attach to.", + "The namespace of the Gateway.": "The namespace of the Gateway.", "The network port that this listener will bind to (1-65535).": "The network port that this listener will bind to (1-65535).", "The protocol that this listener will accept.": "The protocol that this listener will accept.", "The resource is being processed.": "The resource is being processed.", "The status of the resource is unknown.": "The status of the resource is unknown.", "This resource has no related items configured": "This resource has no related items configured", + "This rule cannot be created until it includes at least one match, filter, or backend reference.": "This rule cannot be created until it includes at least one match, filter, or backend reference.", + "This rule has validation errors that must be fixed before creation.": "This rule has validation errors that must be fixed before creation.", "TLS Mode": "TLS Mode", "TLS Option": "TLS Option", "TLS Options": "TLS Options", "TLS termination mode. Terminate decrypts TLS at the gateway, Passthrough forwards encrypted traffic.": "TLS termination mode. Terminate decrypts TLS at the gateway, Passthrough forwards encrypted traffic.", "Try adjusting your search criteria": "Try adjusting your search criteria", "Type": "Type", + "Unique name of the HTTPRoute": "Unique name of the HTTPRoute", "Unnamed": "Unnamed", "Update": "Update", - "Value": "Value" + "URL Rewrite": "URL Rewrite", + "Validation warnings": "Validation warnings", + "Value": "Value", + "Weight": "Weight" } \ No newline at end of file diff --git a/package.json b/package.json index 1d5a675..8fa3279 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,9 @@ "exposedModules": { "GatewaySingleOverview": "./components/GatewaySingleOverview", "HTTPRouteSingleOverview": "./components/HTTPRouteSingleOverview", - "GatewayCreatePage": "./components/GatewayCreatePage" + "GatewayCreatePage": "./components/GatewayCreatePage", + "HTTPRouteCreatePage": "./components/HTTPRouteCreatePage", + "useHTTPRouteActions": "./components/httproute/useHTTPRouteActions" }, "dependencies": { "@console/pluginAPI": "*" diff --git a/src/components/GatewayApiCreateUpdate.tsx b/src/components/GatewayApiCreateUpdate.tsx index 650ea45..b99650e 100644 --- a/src/components/GatewayApiCreateUpdate.tsx +++ b/src/components/GatewayApiCreateUpdate.tsx @@ -9,12 +9,17 @@ import { PageSection, ActionGroup, } from '@patternfly/react-core'; -import { k8sCreate, k8sUpdate } from '@openshift-console/dynamic-plugin-sdk'; +import { + k8sCreate, + k8sUpdate, + K8sModel, + K8sResourceCommon, +} from '@openshift-console/dynamic-plugin-sdk'; -interface GatewayApiCreateUpdateProps { - resource: any; +interface GatewayApiCreateUpdateProps { + resource: TResource; formValidation: boolean; - model: any; + model: K8sModel; ns: string; view: string; resourceKind?: string; @@ -83,12 +88,12 @@ const GatewayApiCreateUpdate: React.FC = ({ const resourcePath = `${model.apiGroup}~${model.apiVersion}~${model.kind}`; history.push(`/k8s/ns/${ns}/${resourcePath}`); } - } catch (error) { + } catch (error: unknown) { const action = update ? 'updating' : 'creating'; setErrorAlertMsg( t(`Error ${action} {{kind}}: {{error}}`, { kind: resourceKind, - error: error, + error: error instanceof Error ? error.message : String(error), }), ); } diff --git a/src/components/GatewayCreatePage.tsx b/src/components/GatewayCreatePage.tsx index 6909e5c..624b753 100644 --- a/src/components/GatewayCreatePage.tsx +++ b/src/components/GatewayCreatePage.tsx @@ -34,11 +34,14 @@ import { ResourceYAMLEditor, useActiveNamespace, useK8sWatchResource, + getGroupVersionKindForResource, + useK8sModel, } from '@openshift-console/dynamic-plugin-sdk'; import * as yaml from 'js-yaml'; import GatewayApiCreateUpdate from './GatewayApiCreateUpdate'; import { useLocation } from 'react-router'; import { GatewayResource } from './gateway/GatewayModel'; +import type { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; import { generateUniqueId, removeCertsAndTlsOptionsForPassthrough, @@ -50,14 +53,16 @@ const GatewayCreatePage: React.FC = () => { const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); const [selectedNamespace] = useActiveNamespace(); const [create, setCreate] = React.useState(true); - const [originalMetadata, setOriginalMetadata] = React.useState(null); + const [originalMetadata, setOriginalMetadata] = React.useState< + K8sResourceCommon['metadata'] | null + >(null); // Gateway settings const [gatewayName, setGatewayName] = React.useState(''); const [gatewayClassName, setGatewayClassName] = React.useState('istio'); // YAML editor - const [yamlContent, setYamlContent] = React.useState(null); + const [yamlContent, setYamlContent] = React.useState(null); const location = useLocation(); const pathSplit = location.pathname.split('/'); @@ -66,17 +71,41 @@ const GatewayCreatePage: React.FC = () => { // Modal const [isModalOpen, setIsModalOpen] = React.useState(false); - const [listeners, setListeners] = React.useState([]); + type TLSOptionRow = { id: string; key: string; value: string }; + type CertificateRefRow = { + id: string; + name: string; + namespace?: string; + kind: 'Secret' | 'ConfigMap'; + }; + type AllowedRouteKindRow = { id: string; kind: string; group: string }; + type AllowedRoutesUI = { + namespaces: { from: 'All' | 'Same' | 'Selector' }; + kinds: AllowedRouteKindRow[]; + }; + type ListenerUI = { + name: string; + protocol: 'HTTP' | 'HTTPS' | 'TLS' | 'TCP' | 'UDP'; + port: number; + hostname: string; + tlsMode: 'Terminate' | 'Passthrough'; + tlsOptions: TLSOptionRow[]; + certificateRefs: CertificateRefRow[]; + allowedRoutes: AllowedRoutesUI; + }; + type AddressRow = { id: string; type: 'IPAddress' | 'Hostname'; value: string }; + + const [listeners, setListeners] = React.useState([]); const [editingListenerIndex, setEditingListenerIndex] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [yamlError, setYamlError] = React.useState(null); // Addresses settings - const [addresses, setAddresses] = React.useState([]); + const [addresses, setAddresses] = React.useState([]); const [isAddressesExpanded, setIsAddressesExpanded] = React.useState(false); // Prefilled data for listener fields (temporary data) - const [currentListener, setCurrentListener] = React.useState({ + const [currentListener, setCurrentListener] = React.useState({ name: '', protocol: 'HTTP', port: 80, @@ -139,12 +168,15 @@ const GatewayCreatePage: React.FC = () => { }); }; - const gatewayModel = { - apiGroup: 'gateway.networking.k8s.io', - apiVersion: 'v1', + const gatewayGVK = getGroupVersionKindForResource({ + apiVersion: 'gateway.networking.k8s.io/v1', kind: 'Gateway', - plural: 'gateways', - }; + }); + const [gatewayModel] = useK8sModel({ + group: gatewayGVK.group, + version: gatewayGVK.version, + kind: gatewayGVK.kind, + }); const handleListenerSave = () => { // Remove certificates and TLS options when TLS mode is Passthrough @@ -185,7 +217,7 @@ const GatewayCreatePage: React.FC = () => { }; const handleAddCertificateRef = () => { - const newRef = { + const newRef: CertificateRefRow = { id: generateUniqueId('cert'), name: '', namespace: selectedNamespace, @@ -197,16 +229,24 @@ const GatewayCreatePage: React.FC = () => { }); }; - const handleCertificateRefChange = (id: number, field: string, value: string) => { + const handleCertificateRefChange = ( + id: string, + field: 'name' | 'namespace' | 'kind', + value: string, + ) => { setCurrentListener({ ...currentListener, certificateRefs: currentListener.certificateRefs.map((ref) => - ref.id === id ? { ...ref, [field]: value } : ref, + ref.id === id + ? field === 'kind' + ? { ...ref, kind: value as CertificateRefRow['kind'] } + : { ...ref, [field]: value } + : ref, ), }); }; - const handleRemoveCertificateRef = (id: number) => { + const handleRemoveCertificateRef = (id: string) => { setCurrentListener({ ...currentListener, certificateRefs: currentListener.certificateRefs.filter((ref) => ref.id !== id), @@ -228,7 +268,7 @@ const GatewayCreatePage: React.FC = () => { }); }; - const handleRouteKindChange = (id: number, field: string, value: string) => { + const handleRouteKindChange = (id: string, field: 'kind' | 'group', value: string) => { setCurrentListener({ ...currentListener, allowedRoutes: { @@ -240,7 +280,7 @@ const GatewayCreatePage: React.FC = () => { }); }; - const handleRemoveRouteKind = (id: number) => { + const handleRemoveRouteKind = (id: string) => { setCurrentListener({ ...currentListener, allowedRoutes: { @@ -262,7 +302,7 @@ const GatewayCreatePage: React.FC = () => { }); }; - const handleTlsOptionChange = (id: number, field: string, value: string) => { + const handleTlsOptionChange = (id: string, field: 'key' | 'value', value: string) => { setCurrentListener({ ...currentListener, tlsOptions: currentListener.tlsOptions.map((option) => @@ -271,7 +311,7 @@ const GatewayCreatePage: React.FC = () => { }); }; - const handleRemoveTlsOption = (id: number) => { + const handleRemoveTlsOption = (id: string) => { setCurrentListener({ ...currentListener, tlsOptions: currentListener.tlsOptions.filter((option) => option.id !== id), @@ -280,7 +320,7 @@ const GatewayCreatePage: React.FC = () => { // Address handlers const handleAddAddress = () => { - const newAddress = { + const newAddress: AddressRow = { id: generateUniqueId('addr'), type: 'IPAddress', value: '', @@ -288,147 +328,187 @@ const GatewayCreatePage: React.FC = () => { setAddresses([...addresses, newAddress]); }; - const handleAddressChange = (id: number, field: string, value: string) => { + const handleAddressChange = (id: string, field: 'type' | 'value', value: string) => { setAddresses( - addresses.map((address) => (address.id === id ? { ...address, [field]: value } : address)), + addresses.map((address) => + address.id === id + ? field === 'type' + ? { ...address, type: value as AddressRow['type'] } + : { ...address, value } + : address, + ), ); }; - const handleRemoveAddress = (id: number) => { + const handleRemoveAddress = (id: string) => { setAddresses(addresses.filter((address) => address.id !== id)); }; // When form completed, build gateway resource object from form data - const gatewayObject = React.useMemo(() => { - const gateway = { - apiVersion: 'gateway.networking.k8s.io/v1', - kind: 'Gateway', - metadata: originalMetadata - ? { - ...originalMetadata, - } - : { - name: gatewayName, - namespace: selectedNamespace, - }, - spec: { - gatewayClassName: gatewayClassName, - listeners: listeners.map((listener) => { - const formattedListener: any = { - name: listener.name, - port: listener.port, - protocol: listener.protocol, + const gatewayObject = React.useMemo(() => { + const baseSpec = { + gatewayClassName: gatewayClassName, + listeners: listeners.map((listener) => { + const formattedListener: { + name: string; + port: number; + protocol: ListenerUI['protocol']; + hostname?: string; + tls?: { + mode?: 'Terminate' | 'Passthrough'; + certificateRefs?: { group?: string; kind?: string; name: string; namespace?: string }[]; + options?: Record; + }; + allowedRoutes?: { + namespaces?: { from?: 'All' | 'Same' | 'Selector' }; + kinds?: { group?: string; kind: string }[]; + }; + } = { + name: listener.name, + port: listener.port, + protocol: listener.protocol, + }; + + if (listener.hostname) { + formattedListener.hostname = listener.hostname; + } + + if (listener.protocol === 'HTTPS' || listener.protocol === 'TLS') { + formattedListener.tls = { + mode: listener.tlsMode, }; - if (listener.hostname) { - formattedListener.hostname = listener.hostname; + if (listener.certificateRefs && listener.certificateRefs.length > 0) { + formattedListener.tls.certificateRefs = listener.certificateRefs.map((ref) => ({ + name: ref.name, + namespace: ref.namespace || undefined, + kind: ref.kind, + })); } - if (listener.protocol === 'HTTPS' || listener.protocol === 'TLS') { - formattedListener.tls = { - mode: listener.tlsMode, - }; - - if (listener.certificateRefs && listener.certificateRefs.length > 0) { - formattedListener.tls.certificateRefs = listener.certificateRefs.map((ref) => ({ - name: ref.name, - namespace: ref.namespace || undefined, - kind: ref.kind, - })); - } - - if (listener.tlsOptions && listener.tlsOptions.length > 0) { - formattedListener.tls.options = listener.tlsOptions.reduce((acc, option) => { + if (listener.tlsOptions && listener.tlsOptions.length > 0) { + formattedListener.tls.options = listener.tlsOptions.reduce>( + (acc, option) => { acc[option.key] = option.value; return acc; - }, {}); - } + }, + {}, + ); } + } - if (listener.allowedRoutes) { - formattedListener.allowedRoutes = { - namespaces: { - from: listener.allowedRoutes.namespaces.from, - }, - }; - - if (listener.allowedRoutes.kinds && listener.allowedRoutes.kinds.length > 0) { - formattedListener.allowedRoutes.kinds = listener.allowedRoutes.kinds.map((kind) => ({ - kind: kind.kind, - group: kind.group, - })); - } + if (listener.allowedRoutes) { + formattedListener.allowedRoutes = { + namespaces: { + from: listener.allowedRoutes.namespaces.from, + }, + }; + + if (listener.allowedRoutes.kinds && listener.allowedRoutes.kinds.length > 0) { + formattedListener.allowedRoutes.kinds = listener.allowedRoutes.kinds.map((kind) => ({ + kind: kind.kind, + group: kind.group, + })); } + } - return formattedListener; - }), - }, - }; + return formattedListener; + }), + ...(addresses.length > 0 + ? { + addresses: addresses.map((address) => ({ + type: address.type, + value: address.value, + })), + } + : {}), + } as const; - if (addresses.length > 0) { - (gateway.spec as any).addresses = addresses.map((address) => ({ - type: address.type, - value: address.value, - })); - } + const gateway: GatewayResource = { + apiVersion: 'gateway.networking.k8s.io/v1', + kind: 'Gateway', + metadata: originalMetadata + ? { + ...originalMetadata, + } + : { + name: gatewayName, + namespace: selectedNamespace, + }, + spec: baseSpec, + }; return gateway; }, [gatewayName, gatewayClassName, listeners, addresses, selectedNamespace, originalMetadata]); - const populateFormFromGateway = (gateway: any) => { + type ApiListener = NonNullable['listeners'][number]; + type ApiAddress = NonNullable['addresses'] extends (infer U)[] + ? U + : never; + + const populateFormFromGateway = (gateway: unknown) => { try { - if (gateway.metadata?.name) { - setGatewayName(gateway.metadata.name); + const g = gateway as Partial; + if (g.metadata?.name) { + setGatewayName(g.metadata.name); } - if (gateway.spec?.gatewayClassName) { - setGatewayClassName(gateway.spec.gatewayClassName); + if (g.spec?.gatewayClassName) { + setGatewayClassName(g.spec.gatewayClassName); } - if (gateway.spec?.listeners) { - const formattedListeners = gateway.spec.listeners.map((listener: any, index: number) => ({ - name: listener.name || '', - port: listener.port || 80, - protocol: listener.protocol || 'HTTP', - hostname: listener.hostname || '', - tlsMode: listener.tls?.mode || 'Terminate', - tlsOptions: listener.tls?.options - ? Object.entries(listener.tls.options).map(([key, value], idx) => ({ - id: generateUniqueId(`tls_${index}_${idx}`), - key, - value, - })) - : [], - certificateRefs: listener.tls?.certificateRefs - ? listener.tls.certificateRefs.map((ref: any, idx: number) => ({ - id: generateUniqueId(`cert_${index}_${idx}`), - name: ref.name || '', - namespace: ref.namespace || '', - kind: ref.kind || 'Secret', - })) - : [], - allowedRoutes: { - namespaces: { - from: listener.allowedRoutes?.namespaces?.from || 'Same', - }, - kinds: listener.allowedRoutes?.kinds - ? listener.allowedRoutes.kinds.map((kind: any, idx: number) => ({ - id: generateUniqueId(`route_${index}_${idx}`), - kind: kind.kind || 'HTTPRoute', - group: kind.group || 'gateway.networking.k8s.io', + if (g.spec?.listeners) { + const formattedListeners: ListenerUI[] = g.spec.listeners.map( + (listener: ApiListener, index: number) => ({ + name: listener.name || '', + port: listener.port || 80, + protocol: (listener.protocol as ListenerUI['protocol']) || 'HTTP', + hostname: listener.hostname || '', + tlsMode: (listener.tls?.mode as ListenerUI['tlsMode']) || 'Terminate', + tlsOptions: listener.tls?.options + ? (Object.entries(listener.tls.options) as Array<[string, string]>).map( + ([key, value], idx) => ({ + id: generateUniqueId(`tls_${index}_${idx}`), + key, + value, + }), + ) + : [], + certificateRefs: listener.tls?.certificateRefs + ? listener.tls.certificateRefs.map((ref, idx: number) => ({ + id: generateUniqueId(`cert_${index}_${idx}`), + name: ref.name || '', + namespace: ref.namespace || '', + kind: (ref.kind as CertificateRefRow['kind']) || 'Secret', })) : [], - }, - })); + allowedRoutes: { + namespaces: { + from: + (listener.allowedRoutes?.namespaces + ?.from as AllowedRoutesUI['namespaces']['from']) || 'Same', + }, + kinds: listener.allowedRoutes?.kinds + ? listener.allowedRoutes.kinds.map((kind, idx: number) => ({ + id: generateUniqueId(`route_${index}_${idx}`), + kind: kind.kind || 'HTTPRoute', + group: kind.group || 'gateway.networking.k8s.io', + })) + : [], + }, + }), + ); setListeners(formattedListeners); } - if (gateway.spec?.addresses) { - const formattedAddresses = gateway.spec.addresses.map((address: any, index: number) => ({ - id: generateUniqueId(`addr_${index}`), - type: address.type || 'IPAddress', - value: address.value || '', - })); + if (g.spec?.addresses) { + const formattedAddresses: AddressRow[] = g.spec.addresses.map( + (address: ApiAddress, index: number) => ({ + id: generateUniqueId(`addr_${index}`), + type: (address.type as AddressRow['type']) || 'IPAddress', + value: address.value || '', + }), + ); setAddresses(formattedAddresses); } } catch (error) { @@ -473,9 +553,10 @@ const GatewayCreatePage: React.FC = () => { if (parsedGateway && typeof parsedGateway === 'object') { populateFormFromGateway(parsedGateway); } - } catch (error: any) { + } catch (error: unknown) { + const err = error as Error; const errorMessage = - error.message || + err?.message || 'Invalid YAML syntax. Please review the Gateway Resource YAML and try again.'; setYamlError(errorMessage); console.warn('Invalid YAML syntax, not updating form:', error); @@ -585,7 +666,10 @@ const GatewayCreatePage: React.FC = () => { - setCurrentListener({ ...currentListener, protocol: value }) + setCurrentListener({ + ...currentListener, + protocol: value as ListenerUI['protocol'], + }) } aria-label={t('Select Protocol')} > @@ -612,7 +696,10 @@ const GatewayCreatePage: React.FC = () => { - setCurrentListener({ ...currentListener, tlsMode: value }) + setCurrentListener({ + ...currentListener, + tlsMode: value as ListenerUI['tlsMode'], + }) } aria-label={t('Select TLS Mode')} > @@ -830,7 +917,7 @@ const GatewayCreatePage: React.FC = () => { ...currentListener.allowedRoutes, namespaces: { ...currentListener.allowedRoutes.namespaces, - from: value, + from: value as AllowedRoutesUI['namespaces']['from'], }, }, }) @@ -1050,7 +1137,7 @@ const GatewayCreatePage: React.FC = () => { {createView === 'form' ? (
- + { diff --git a/src/components/HTTPRouteCreatePage.tsx b/src/components/HTTPRouteCreatePage.tsx new file mode 100644 index 0000000..932e825 --- /dev/null +++ b/src/components/HTTPRouteCreatePage.tsx @@ -0,0 +1,952 @@ +import * as React from 'react'; +import Helmet from 'react-helmet'; +import { + PageSection, + Title, + TextInput, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Form, + Radio, + Button, + ButtonVariant, + Alert, + Modal, + AlertVariant, + ActionGroup, + Popover, +} from '@patternfly/react-core'; +import { PlusCircleIcon, MinusCircleIcon, HelpIcon } from '@patternfly/react-icons'; +import { useTranslation } from 'react-i18next'; +import { + ResourceYAMLEditor, + getGroupVersionKindForResource, + useK8sModel, + useK8sWatchResource, + useActiveNamespace, +} from '@openshift-console/dynamic-plugin-sdk'; +import { useLocation, useHistory } from 'react-router-dom'; +import { k8sCreate, k8sUpdate } from '@openshift-console/dynamic-plugin-sdk'; +import * as yaml from 'js-yaml'; +import ParentReferencesSelect, { + validateAllParentReferences, +} from '../utils/ParentReferencesSelect'; +import { + ActionsColumn, + IAction, + Table, + TableText, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import { + HTTPRouteResource, + HTTPRouteMatch, + HTTPRouteHeader, + HTTPRouteQueryParam, +} from './httproute/HTTPRouteModel'; +import { generateFiltersForYAML, parseFiltersFromYAML } from './httproute/filters/filterUtils'; +import HTTPRouteRuleWizard from './httproute/HTTPRouteRuleWizard'; +import { + areBackendRefsValid, + generateBackendRefsForYAML, + parseBackendRefsFromYAML, +} from './httproute/backend-refs/backendUtils'; +import { HTTPRouteBackendRef } from './httproute/backend-refs/backendTypes'; +import { validateCompleteRule } from './httproute/review/reviewValidation'; +import './css/gateway-api-plugin.css'; +const generateMatchesForYAML = (matches: HTTPRouteMatch[]) => { + if (!matches || matches.length === 0) { + return []; + } + + return matches + .map((match) => { + const yamlMatch: { + path: { type: HTTPRouteMatch['pathType']; value: string }; + method?: string; + headers?: { + type: HTTPRouteMatch['headers'][number]['type']; + name: string; + value: string; + }[]; + queryParams?: { + type: HTTPRouteMatch['queryParams'][number]['type']; + name: string; + value: string; + }[]; + } = { + path: { + type: match.pathType, + value: match.pathValue, + }, + }; + if (match.method) { + yamlMatch.method = match.method; + } + + if (match.headers && match.headers.length > 0) { + const validHeaders = match.headers + .filter((h) => h.name && h.value && h.name.trim() !== '' && h.value.trim() !== '') + .map((h) => ({ + type: h.type, + name: h.name, + value: h.value, + })); + + if (validHeaders.length > 0) { + yamlMatch.headers = validHeaders; + } + } + + if (match.queryParams && match.queryParams.length > 0) { + const validQueryParams = match.queryParams + .filter((q) => q.name && q.value && q.name.trim() !== '' && q.value.trim() !== '') + .map((q) => ({ + type: q.type, + name: q.name, + value: q.value, + })); + + if (validQueryParams.length > 0) { + yamlMatch.queryParams = validQueryParams; + } + } + + return yamlMatch; + }) + .filter(Boolean); +}; + +const parseMatchesFromYAML = ( + yamlMatches: Array< + | undefined + | null + | { + path?: { type?: string; value?: string }; + method?: string; + headers?: Array<{ type?: string; name?: string; value?: string }>; + queryParams?: Array<{ type?: string; name?: string; value?: string }>; + } + >, +): HTTPRouteMatch[] => { + if (!yamlMatches || !Array.isArray(yamlMatches)) { + return []; + } + + return yamlMatches.map((match, matchIndex: number) => ({ + id: `match-${Date.now()}-${matchIndex}`, + pathType: match.path?.type || '', + pathValue: match.path?.value || '/', + method: match.method || '', + headers: match.headers + ? match.headers.map( + (header, headerIndex: number): HTTPRouteHeader => ({ + id: `header-${Date.now()}-${headerIndex}`, + type: (header.type as HTTPRouteHeader['type']) || 'Exact', + name: header.name || '', + value: header.value || '', + }), + ) + : [], + queryParams: match.queryParams + ? match.queryParams.map( + (queryParam, queryParamIndex: number): HTTPRouteQueryParam => ({ + id: `queryparam-${Date.now()}-${queryParamIndex}`, + type: (queryParam.type as HTTPRouteQueryParam['type']) || 'Exact', + name: queryParam.name || '', + value: queryParam.value || '', + }), + ) + : [], + })); +}; + +const validateMatchesInRule = (matches: HTTPRouteMatch[]): boolean => { + return ( + matches.length === 0 || + matches.every((match) => match.pathType && match.pathValue && match.method) + ); +}; + +const formatMatchesForDisplay = (matches: any[], t: any) => { + if (!matches || matches.length === 0) { + return ; + } + + return ( + +
+ {matches.slice(0, 3).map((match, idx) => ( +
+ {match.pathType || 'Not set'} {match.pathValue || '/'} | {match.method || 'Not set'} +
+ ))} + {matches.length > 3 && ( + + {matches.slice(3).map((match, idx) => ( +
+ {match.pathType || 'Not set'}| {match.pathValue || '/'} |{' '} + {match.method || 'Not set'} +
+ ))} +
+ } + > + + + )} + +
+ ); +}; + +const formatFiltersForDisplay = (filters: any[], t: any) => { + if (!filters || filters.length === 0) { + return ; + } + + return ( + +
+ {filters.slice(0, 3).map((filter, idx) => ( +
+ {filter.type || 'Unknown Filter'} +
+ ))} + {filters.length > 3 && ( + + {filters.slice(3).map((filter, idx) => ( +
+ {filter.type || 'Unknown Filter'} +
+ ))} +
+ } + > + + + )} + +
+ ); +}; + +const formatBackendsForDisplay = (rule: any, t: any) => { + if (rule.serviceName && rule.servicePort) { + return ( + +
+ {rule.serviceName}: {rule.servicePort} +
+
+ ); + } + + if (!rule.backendRefs || rule.backendRefs.length === 0) { + return ; + } + + return ( + +
+ {rule.backendRefs.slice(0, 3).map((ref: any, idx: number) => ( +
+ {ref.serviceName || 'Not set'}:{' '} + {ref.port > 0 ? ref.port : t('Not set')} + {ref.weight !== 1 && ` (weight: ${ref.weight})`} +
+ ))} + {rule.backendRefs.length > 3 && ( + + {rule.backendRefs.slice(3).map((ref: any, idx: number) => ( +
+ {ref.serviceName}: {ref.port > 0 ? ref.port : t('Not set')} + {ref.weight !== 1 && ` (weight: ${ref.weight})`} +
+ ))} +
+ } + > + + + )} + +
+ ); +}; + +interface ParentReference { + id: string; + gatewayName: string; + gatewayNamespace: string; + sectionName: string; + port: number; + isExpanded?: boolean; +} + +const HTTPRouteCreatePage: React.FC = () => { + const { t } = useTranslation('plugin__gateway-api-console-plugin'); + const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); + const [routeName, setRouteName] = React.useState(''); + const [hostnames, setHostnames] = React.useState([]); + const [selectedNamespaceRaw] = useActiveNamespace(); + + // YAML editor state + const [yamlContent, setYamlContent] = React.useState(null); + const [yamlError, setYamlError] = React.useState(null); + const [parentRefs, setParentRefs] = React.useState([]); + + // Metadata for determining edit/create mode + const [originalMetadata, setOriginalMetadata] = React.useState< + HTTPRouteResource['metadata'] | null + >(null); + + // Determine mode by checking originalMetadata + const isEdit = !!originalMetadata; + type RuleUI = { + id: string; + matches: HTTPRouteMatch[]; + filters: ReturnType; + backendRefs: HTTPRouteBackendRef[]; + }; + const [rules, setRules] = React.useState([]); + const [isRuleModalOpen, setIsRuleModalOpen] = React.useState(false); + + const [currentRule, setCurrentRule] = React.useState({ + id: 'rule-1', + matches: [], // Array of match objects + filters: [], // Filters array + backendRefs: [], + }); + + const [editingRuleIndex, setEditingRuleIndex] = React.useState(null); + + const location = useLocation(); + const history = useHistory(); + const pathSplit = location.pathname.split('/'); + const nameEdit = pathSplit[5]; + const namespaceEdit = pathSplit[3]; + const [formDisabled] = React.useState(false); + const selectedNamespace = + !selectedNamespaceRaw || selectedNamespaceRaw === '#ALL_NS#' ? 'default' : selectedNamespaceRaw; + // Function to add a new hostname field + const addHostnameField = () => { + setHostnames([...hostnames, '']); + }; + + //Function to remove a hostname field + const removeHostnameField = (index: number) => { + const newHostnames = hostnames.filter((_, i) => i !== index); + setHostnames(newHostnames); + }; + + // Function to update a hostname value + const updateHostname = (value: string, index: number) => { + const newHostnames = [...hostnames]; + newHostnames[index] = value; + setHostnames(newHostnames); + }; + + // When form completed, build HTTPRoute resource object from form data (following Gateway pattern) + const httpRouteObject = React.useMemo(() => { + // Filter out empty hostnames + const validHostnames = hostnames.filter((h) => h.trim().length > 0); + const validParentRefs = parentRefs.filter((ref) => ref.gatewayName && ref.sectionName); + + const httpRoute = { + apiVersion: 'gateway.networking.k8s.io/v1', + kind: 'HTTPRoute', + metadata: originalMetadata + ? { + ...originalMetadata, + name: routeName, + } + : { + name: routeName, + namespace: selectedNamespace, + }, + spec: { + parentRefs: validParentRefs.map((ref) => ({ + name: ref.gatewayName, + ...(ref.gatewayNamespace !== selectedNamespace + ? { namespace: ref.gatewayNamespace } + : {}), + ...(ref.sectionName ? { sectionName: ref.sectionName } : {}), + ...(ref.port ? { port: ref.port } : {}), + })), + ...(validHostnames.length > 0 ? { hostnames: validHostnames } : {}), + rules: rules.map((rule) => ({ + ...(rule.matches.length > 0 ? { matches: generateMatchesForYAML(rule.matches) } : {}), + ...(rule.filters && rule.filters.length > 0 + ? { filters: generateFiltersForYAML(rule.filters) } + : {}), + ...(rule.backendRefs && rule.backendRefs.length > 0 + ? { backendRefs: generateBackendRefsForYAML(rule.backendRefs) } + : {}), + })), + }, + }; + + return httpRoute; + }, [routeName, hostnames, parentRefs, rules, selectedNamespace, originalMetadata]); + + const populateFormFromHTTPRoute = (httpRoute: unknown) => { + try { + const hr = httpRoute as Partial; + if (hr.metadata?.name && hr.metadata.name !== routeName) setRouteName(hr.metadata.name); + + const newHostnames = hr.spec?.hostnames || []; + if (JSON.stringify(newHostnames) !== JSON.stringify(hostnames)) { + setHostnames(newHostnames); + } + + if (hr.spec?.parentRefs && hr.spec.parentRefs.length > 0) { + const formattedParentRefs: ParentReference[] = hr.spec.parentRefs.map( + (ref, index: number) => { + const hasSectionName = ref.sectionName && ref.sectionName.trim() !== ''; + const hasPort = ref.port && ref.port > 0; + const shouldResetBoth = !hasSectionName || !hasPort; // Mutual dependency logic + + return { + id: `parent-${Date.now()}-${index}`, + gatewayName: ref.name || '', + gatewayNamespace: ref.namespace || selectedNamespace, + sectionName: shouldResetBoth ? '' : ref.sectionName, + port: shouldResetBoth ? 0 : ref.port, + }; + }, + ); + if (JSON.stringify(formattedParentRefs) !== JSON.stringify(parentRefs)) + setParentRefs(formattedParentRefs); + } + + if (hr.spec?.rules && hr.spec.rules.length > 0) { + const formattedRules = hr.spec.rules.map((rule, index: number) => ({ + id: rules[index]?.id || `rule-${index + 1}`, + matches: parseMatchesFromYAML(rule.matches), + filters: parseFiltersFromYAML(rule.filters), + backendRefs: parseBackendRefsFromYAML(rule.backendRefs || []), + })); + if (JSON.stringify(formattedRules) !== JSON.stringify(rules)) setRules(formattedRules); + } + + // Keep form enabled in edit mode to allow changes + } catch (error) { + console.error('Error populating form from HTTPRoute:', error); + } + }; + + const httpRouteGVK = getGroupVersionKindForResource({ + apiVersion: 'gateway.networking.k8s.io/v1', + kind: 'HTTPRoute', + }); + + const [httpRouteModel] = useK8sModel({ + group: httpRouteGVK.group, + version: httpRouteGVK.version, + kind: httpRouteGVK.kind, + }); + + // Check if there is an HTTPRoute for editing + let httpRouteResource = null; + if (nameEdit && nameEdit !== '~new') { + httpRouteResource = { + groupVersionKind: httpRouteGVK, + isList: false, + name: nameEdit, + namespace: namespaceEdit, + }; + } + + const [httpRouteData, httpRouteLoaded, httpRouteError] = httpRouteResource + ? useK8sWatchResource(httpRouteResource) + : [null, true, null]; // If no resource to load, consider it loaded + + const hasInitializedFromResource = React.useRef(false); + + React.useEffect(() => { + if (httpRouteResource && httpRouteLoaded && !httpRouteError && !Array.isArray(httpRouteData)) { + const httpRouteUpdate = httpRouteData as HTTPRouteResource; + setOriginalMetadata(httpRouteUpdate.metadata); + + if (!hasInitializedFromResource.current) { + populateFormFromHTTPRoute(httpRouteUpdate); + hasInitializedFromResource.current = true; + + // Set initial YAML content if in YAML view + if (createView === 'yaml') { + setYamlContent(httpRouteUpdate); + } + } + } else if (httpRouteError) { + console.error('Failed to fetch the HTTPRoute resource:', httpRouteError); + } + }, [httpRouteData, httpRouteLoaded, httpRouteError, httpRouteResource, createView]); + + const parseYAMLToForm = (yamlInput: string) => { + setYamlError(null); + try { + const parsedHTTPRoute = yaml.load(yamlInput); + if (parsedHTTPRoute && typeof parsedHTTPRoute === 'object') { + populateFormFromHTTPRoute(parsedHTTPRoute); + } + } catch (error: unknown) { + const err = error as Error; + const errorMessage = + err?.message || + 'Invalid YAML syntax. Please review the HTTPRoute Resource YAML and try again.'; + setYamlError(errorMessage); + console.warn('Invalid YAML syntax, not updating form:', error); + } + }; + + const handleYAMLChange = (yamlInput: string) => { + setYamlContent(yamlInput); + }; + + const handleViewSwitch = (newView: 'form' | 'yaml') => { + if (newView === 'form' && createView === 'yaml') { + // Switching from YAML to form - sync YAML to form + if (yamlContent) { + parseYAMLToForm( + typeof yamlContent === 'string' ? yamlContent : JSON.stringify(yamlContent), + ); + } + } else if (newView === 'yaml' && createView === 'form') { + try { + setYamlContent(httpRouteObject); + } catch (error) { + console.error('Error setting YAML content:', error); + } + } + setCreateView(newView); + }; + + const handleRouteNameChange = (_event: React.FormEvent, name: string) => { + setRouteName(name); + }; + + const formValidation = () => { + const hasValidParentRef = validateAllParentReferences(parentRefs); + + const hasValidRules = + rules.length > 0 && + rules.every((rule) => { + const basicFieldsValid = rule.id; + const backendRefsValid = areBackendRefsValid(rule.backendRefs || []); + const matchesValid = validateMatchesInRule(rule.matches); + const reviewValid = validateCompleteRule(rule).isValid; + + return basicFieldsValid && matchesValid && backendRefsValid && reviewValid; + }); + + return !!(routeName && hasValidParentRef && hasValidRules); + }; + + const [submitError, setSubmitError] = React.useState(null); + const isUpdate = Boolean(originalMetadata?.creationTimestamp); + + const handleSubmit = async () => { + if (!formValidation()) return; + setSubmitError(null); + try { + const httpRouteResource = httpRouteObject as HTTPRouteResource; + if (isUpdate) { + await k8sUpdate({ + model: httpRouteModel, + data: httpRouteResource, + ns: httpRouteResource.metadata.namespace, + name: httpRouteResource.metadata.name, + }); + } else { + await k8sCreate({ + model: httpRouteModel, + data: httpRouteResource, + ns: httpRouteResource.metadata.namespace, + }); + } + const resourcePath = `${httpRouteModel.apiGroup}~${httpRouteModel.apiVersion}~${httpRouteModel.kind}`; + history.push(`/k8s/ns/${httpRouteResource.metadata.namespace}/${resourcePath}`); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setSubmitError(message); + } + }; + + const handleCancel = () => history.goBack(); + + const handleAddRule = () => { + setEditingRuleIndex(null); + setCurrentRule({ + id: `rule-${Date.now().toString(36)}`, + matches: [], + filters: [], + backendRefs: [], + }); + setIsRuleModalOpen(true); + }; + + const handleRuleModalClose = () => { + setIsRuleModalOpen(false); + }; + + const handleRuleSave = () => { + let newRules: RuleUI[]; + if (editingRuleIndex !== null) { + // EDIT mode - replace existing rule + newRules = [...rules]; + newRules[editingRuleIndex] = { ...currentRule }; + } else { + // CREATE mode - add new rule + newRules = [...rules, { ...currentRule }]; + } + setRules(newRules); + setIsRuleModalOpen(false); + console.log('Rule saved:', currentRule); + }; + + const handleEditRule = (index: number) => { + setEditingRuleIndex(index); // Edit mode + setCurrentRule({ + ...rules[index], + filters: rules[index].filters || [], + backendRefs: rules[index].backendRefs || [], + }); // Load data into form + setIsRuleModalOpen(true); // Open modal + }; + + const handleRemoveRule = (index: number) => { + const newRules = rules.filter((_, i) => i !== index); + setRules(newRules); + }; + + return ( + <> + + + {isEdit ? t('Edit HTTPRoute') : t('Create HTTPRoute')} + + + +
+ {isEdit ? t('Edit HTTPRoute') : t('Create HTTPRoute')} +

+

+ {isEdit + ? t('Edit an HTTPRoute to route traffic from the Gateway to backend services.') + : t('Create an HTTPRoute to route traffic from the Gateway to backend services.')} +
+

+
+
+ Create via: + handleViewSwitch('form')} + /> + handleViewSwitch('yaml')} + /> +
+
+ + {createView === 'form' ? ( + + + + {t('HTTPRoute name')} * + + } + fieldId="route-name" + > + + + + {t('Unique name of the HTTPRoute')} + + + + + + {t('Hostnames')}{' '} + +

{t('Matches traffic for these hostnames.')}

+
    +
  • {t('Supports wildcards (e.g., *.example.com).')}
  • +
  • {t('Inherits from parent listener if empty.')}
  • +
+ + } + aria-label={t('Hostnames help')} + > + +
+ + } + fieldId="hostnames" + > + {hostnames.map((hostname, index) => ( +
+ updateHostname(value, index)} + placeholder={t('example.com')} + isDisabled={formDisabled} + /> + {hostnames.length > 0 && !formDisabled && ( + + )} +
+ ))} + {!formDisabled && ( + + )} +
+ + + + +
+ {t('Rules')} * + +

{t('Rules are used for matching and processing requests. ')}

+
    +
  • {t('Requests are evaluated against rules in order.')}
  • +
  • {t('The first rule that matches is used.')}
  • +
+
+ } + aria-label={t('Rules help')} + > + + + + + + + } + fieldId="rules" + > + {rules.length === 0 && ( + + )} + + {rules.length > 0 && ( +
+ + + + + + + + + + + {rules.map((rule, index) => { + // actions for each rule + const ruleActions: IAction[] = [ + { + title: t('Edit'), + onClick: () => handleEditRule(index), + }, + { + isSeparator: true, + }, + { + title: t('Delete'), + onClick: () => handleRemoveRule(index), + }, + ]; + + return ( + + + + + + + + ); + })} + +
{t('Rule ID')}{t('Matches')}{t('Filters')}{t('Backend References')} +
+ + {rule.id} + + + {formatMatchesForDisplay(rule.matches, t)} + + {formatFiltersForDisplay(rule.filters, t)} + + {formatBackendsForDisplay(rule, t)} + + +
+
+ )} +
+ + {submitError && ( + + {submitError} + + )} + + + + + +
+ ) : ( + <> + {yamlError && ( + + + {yamlError} + + + )} + {t('Loading YAML editor...')}}> + + + + )} + + + + + ); +}; + +export default HTTPRouteCreatePage; diff --git a/src/components/css/gateway-api-plugin.css b/src/components/css/gateway-api-plugin.css index 8246320..4277a66 100644 --- a/src/components/css/gateway-api-plugin.css +++ b/src/components/css/gateway-api-plugin.css @@ -2,9 +2,9 @@ display: flex; padding: var(--pf-t--global--spacer--md) 0; border-bottom: var(--pf-t--global--border--width--regular) solid - var(--pf-t--global--border--color--default); + var(--pf-t--global--border--color--default); border-top: var(--pf-t--global--border--width--regular) solid - var(--pf-t--global--border--color--default); + var(--pf-t--global--border--color--default); } .gateway-editor-toggle span { @@ -14,3 +14,29 @@ .gateway-editor-toggle .pf-v6-c-radio { padding: 0 0 0 0.5rem; } + +.rules-table-wrapper { + border: 1px solid var(--pf-t--global--border--color--default); + border-radius: var(--pf-t--global--border--radius--medium); + overflow: hidden; +} + +.rules-table-wrapper .pf-v6-c-table { + border: none; +} + +.rules-table-wrapper .pf-v6-c-table thead tr:first-child th:first-child { + border-top-left-radius: var(--pf-t--global--border--radius--medium); +} + +.rules-table-wrapper .pf-v6-c-table thead tr:first-child th:last-child { + border-top-right-radius: var(--pf-t--global--border--radius--medium); +} + +.rules-table-wrapper .pf-v6-c-table tbody tr:last-child td:first-child { + border-bottom-left-radius: var(--pf-t--global--border--radius--medium); +} + +.rules-table-wrapper .pf-v6-c-table tbody tr:last-child td:last-child { + border-bottom-right-radius: var(--pf-t--global--border--radius--medium); +} \ No newline at end of file diff --git a/src/components/httproute/HTTPRouteModel.tsx b/src/components/httproute/HTTPRouteModel.tsx new file mode 100644 index 0000000..5e68469 --- /dev/null +++ b/src/components/httproute/HTTPRouteModel.tsx @@ -0,0 +1,98 @@ +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { HTTPRouteFilter } from './filters/filterTypes'; + +export interface HTTPRouteResource extends K8sResourceCommon { + spec?: { + parentRefs?: { + group?: string; + kind?: string; + name: string; + namespace?: string; + sectionName?: string; + port?: number; + }[]; + hostnames?: string[]; + rules?: { + matches?: { + path?: { + type?: 'Exact' | 'PathPrefix' | 'RegularExpression'; + value?: string; + }; + headers?: { + type?: 'Exact' | 'RegularExpression'; + name: string; + value: string; + }[]; + queryParams?: { + type?: 'Exact' | 'RegularExpression'; + name: string; + value: string; + }[]; + method?: + | 'GET' + | 'HEAD' + | 'POST' + | 'PUT' + | 'DELETE' + | 'CONNECT' + | 'OPTIONS' + | 'TRACE' + | 'PATCH'; + }[]; + filters?: HTTPRouteFilter[]; + backendRefs?: { + group?: string; + kind?: string; + name: string; + namespace?: string; + port?: number; + weight?: number; + }[]; + }[]; + }; + status?: { + parents?: { + parentRef: { + group?: string; + kind?: string; + name: string; + namespace?: string; + sectionName?: string; + port?: number; + }; + controllerName: string; + conditions?: { + type: string; + status: string; + observedGeneration?: number; + lastTransitionTime?: string; + reason?: string; + message?: string; + }[]; + }[]; + }; +} + +// Form-specific interfaces for UI state management +export interface HTTPRouteHeader { + id: string; + type: 'Exact' | 'RegularExpression'; + name: string; + value: string; +} + +export interface HTTPRouteQueryParam { + id: string; + type: 'Exact' | 'RegularExpression'; + name: string; + value: string; +} + +export interface HTTPRouteMatch { + id: string; + pathType: string; + pathValue: string; + method: string; + headers?: HTTPRouteHeader[]; + queryParams?: HTTPRouteQueryParam[]; +} diff --git a/src/components/httproute/HTTPRouteRuleWizard.tsx b/src/components/httproute/HTTPRouteRuleWizard.tsx new file mode 100644 index 0000000..4e620f0 --- /dev/null +++ b/src/components/httproute/HTTPRouteRuleWizard.tsx @@ -0,0 +1,647 @@ +import * as React from 'react'; +import { + FormGroup, + Form, + Button, + FormSelect, + FormSelectOption, + TextInput, + ButtonVariant, + Tabs, + Tab, + TabTitleText, + TabContent, + TabContentBody, + ExpandableSection, + Wizard, + WizardStep, + WizardHeader, +} from '@patternfly/react-core'; +import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons'; +import { HTTPRouteMatch, HTTPRouteHeader, HTTPRouteQueryParam } from './HTTPRouteModel'; +import FilterActions from './filters/FilterActions'; +import { HTTPRouteFilter } from './filters/filterTypes'; +import { validateFiltersStep } from './filters/filterUtils'; +import { HTTPRouteBackendRef } from './backend-refs/backendTypes'; +import { areBackendRefsValid } from './backend-refs/backendUtils'; +import BackendReferencesWizardStep from './backend-refs/BackendActions'; +import ReviewStep from './review/ReviewStep'; +import { validateCompleteRule } from './review/reviewValidation'; + +interface HTTPRouteRuleWizardProps { + isOpen: boolean; + onClose: () => void; + onSave: () => void; + currentRule: { + id: string; + matches: HTTPRouteMatch[]; + filters: HTTPRouteFilter[]; + backendRefs: HTTPRouteBackendRef[]; + }; + setCurrentRule: (rule: { + id: string; + matches: HTTPRouteMatch[]; + filters: HTTPRouteFilter[]; + backendRefs: HTTPRouteBackendRef[]; + }) => void; + editingRuleIndex: number | null; + t: (key: string) => string; +} + +const validateMatchesStep = (currentRule: { matches: HTTPRouteMatch[] }): boolean => { + if (currentRule.matches.length === 0) { + return true; + } + return currentRule.matches.every( + (match) => match.pathType && match.pathType !== '' && match.method && match.method !== '', + ); +}; + +export const HTTPRouteRuleWizard: React.FC = ({ + isOpen, + onClose, + onSave, + currentRule, + setCurrentRule, + editingRuleIndex, + t, +}) => { + const [activeMatchTab, setActiveMatchTab] = React.useState(0); + const [isMatchesValid, setIsMatchesValid] = React.useState(true); + const [isFiltersValid, setIsFiltersValid] = React.useState(true); + + React.useEffect(() => { + setIsMatchesValid(validateMatchesStep(currentRule)); + }, [currentRule.matches]); + + React.useEffect(() => { + setIsFiltersValid(validateFiltersStep(currentRule.filters || [])); + }, [currentRule.filters]); + + const [isBackendRefsValid, setIsBackendRefsValid] = React.useState(true); + + React.useEffect(() => { + setIsBackendRefsValid(areBackendRefsValid(currentRule.backendRefs || [])); + }, [currentRule.backendRefs]); + + const [isReviewValid, setIsReviewValid] = React.useState(true); + + React.useEffect(() => { + const validationResult = validateCompleteRule(currentRule); + + setIsReviewValid(validationResult.isValid && validationResult.errors.length === 0); + }, [currentRule.matches, currentRule.filters, currentRule.backendRefs]); + + // Matches handling functions + const handleAddMatch = () => { + const newMatch: HTTPRouteMatch = { + id: `match-${Date.now().toString(36)}`, + pathType: '', + pathValue: '/', + method: '', + headers: [], + queryParams: [], + }; + + const updatedMatches = [...currentRule.matches, newMatch]; + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + + // Switch to a new tab + setActiveMatchTab(updatedMatches.length - 1); + }; + + const handleMatchTabSelect = ( + _event: React.MouseEvent | React.KeyboardEvent | unknown, + tabIndex: number, + ) => { + setActiveMatchTab(tabIndex); + }; + + const handleRemoveMatch = (matchIndex: number) => { + const updatedMatches = currentRule.matches.filter((_, i) => i !== matchIndex); + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + // Adjust the active tab + if (activeMatchTab >= updatedMatches.length && updatedMatches.length > 0) { + setActiveMatchTab(updatedMatches.length - 1); + } else if (updatedMatches.length === 0) { + setActiveMatchTab(0); + } + }; + + const handleAddHeader = (matchIndex: number) => { + const newHeader: HTTPRouteHeader = { + id: `header-${Date.now().toString(36)}`, + type: 'Exact', + name: '', + value: '', + }; + + const updatedMatches = [...currentRule.matches]; + updatedMatches[matchIndex] = { + ...updatedMatches[matchIndex], + headers: [...(updatedMatches[matchIndex].headers || []), newHeader], + }; + + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + }; + + const handleAddQueryParam = (matchIndex: number) => { + const newQueryParam: HTTPRouteQueryParam = { + id: `queryparam-${Date.now().toString(36)}`, + type: 'Exact', + name: '', + value: '', + }; + + const updatedMatches = [...currentRule.matches]; + updatedMatches[matchIndex] = { + ...updatedMatches[matchIndex], + queryParams: [...(updatedMatches[matchIndex].queryParams || []), newQueryParam], + }; + + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + }; + + const handleQueryParamChange = ( + matchIndex: number, + queryParamId: string, + field: keyof HTTPRouteQueryParam, + value: string, + ) => { + const updatedMatches = [...currentRule.matches]; + updatedMatches[matchIndex] = { + ...updatedMatches[matchIndex], + queryParams: (updatedMatches[matchIndex].queryParams || []).map( + (queryParam: HTTPRouteQueryParam) => + queryParam.id === queryParamId ? { ...queryParam, [field]: value } : queryParam, + ), + }; + + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + }; + + const handleRemoveQueryParam = (matchIndex: number, queryParamId: string) => { + const updatedMatches = [...currentRule.matches]; + updatedMatches[matchIndex] = { + ...updatedMatches[matchIndex], + queryParams: (updatedMatches[matchIndex].queryParams || []).filter( + (queryParam: HTTPRouteQueryParam) => queryParam.id !== queryParamId, + ), + }; + + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + }; + + const handleHeaderChange = ( + matchIndex: number, + headerId: string, + field: keyof HTTPRouteHeader, + value: string, + ) => { + const updatedMatches = [...currentRule.matches]; + updatedMatches[matchIndex] = { + ...updatedMatches[matchIndex], + headers: (updatedMatches[matchIndex].headers || []).map((header: HTTPRouteHeader) => + header.id === headerId ? { ...header, [field]: value } : header, + ), + }; + + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + }; + + const handleRemoveHeader = (matchIndex: number, headerId: string) => { + const updatedMatches = [...currentRule.matches]; + updatedMatches[matchIndex] = { + ...updatedMatches[matchIndex], + headers: (updatedMatches[matchIndex].headers || []).filter( + (header: HTTPRouteHeader) => header.id !== headerId, + ), + }; + + setCurrentRule({ + ...currentRule, + matches: updatedMatches, + }); + }; + + const ruleWizardSteps = [ + { + name: t('Matches'), + nextButtonText: t('Next'), + canJumpTo: validateMatchesStep(currentRule), + enableNext: validateMatchesStep(currentRule), + form: ( +
+ {/* If there are no matches, we show an empty state */} + {currentRule.matches.length === 0 ? ( +
+

{t('Matches')}

+

+ {t( + 'Defines the criteria for a request to match this rule. If multiple matches are specified, they are OR ed. If omitted, this rule matches all requests. Multiple Matches in one Rule share all BackendRefs.', + )} +

+ +
+ ) : ( + // If there are matches - show tabs +
+
+

{t('Matches')}

+

+ {t( + 'Defines the criteria for a request to match this rule. If multiple matches are specified, they are OR ed. If omitted, this rule matches all requests.', + )} +

+
+ + + {currentRule.matches.map((match, index) => ( + + Match-{index + 1} + + } + tabContentId={`match-content-${index}`} + /> + ))} + + + {/* Tab Contents */} + {currentRule.matches.map((match, index) => ( + + ))} +
+ )} +
+ ), + }, + { + name: t('Filters'), + nextButtonText: t('Next'), + form: ( + setCurrentRule({ ...currentRule, filters })} + /> + ), + }, + { + name: t('Backend References'), + nextButtonText: t('Next'), + form: ( + + ), + }, + { + name: t('Review and create'), + nextButtonText: t('Create'), + form: , + }, + ]; + + if (!isOpen) { + return null; + } + + return ( + + } + > + {ruleWizardSteps.map((step, index) => ( + + {step.form} + + ))} + + ); +}; + +export default HTTPRouteRuleWizard; diff --git a/src/components/httproute/backend-refs/BackendActions.tsx b/src/components/httproute/backend-refs/BackendActions.tsx new file mode 100644 index 0000000..fa44a60 --- /dev/null +++ b/src/components/httproute/backend-refs/BackendActions.tsx @@ -0,0 +1,360 @@ +import * as React from 'react'; +import { + Form, + Button, + TabContentBody, + TabContent, + Tooltip, + Tabs, + TabTitleText, + Tab, + FormGroup, + TextInput, + FormSelect, + FormSelectOption, + Alert, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { HTTPRouteBackendRef, BackendReferencesWizardStepProps, K8sService } from './backendTypes'; +import { useK8sWatchResource, useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; + +export const BackendReferencesWizardStep: React.FC = ({ + currentRule, + setCurrentRule, + t, +}) => { + const [activeBackendTab, setActiveBackendTab] = React.useState(0); + const [currentNamespace] = useActiveNamespace(); + const [availableServices, setAvailableServices] = React.useState([]); + + // Load all available Services + const serviceResource = { + groupVersionKind: { + group: '', + version: 'v1', + kind: 'Service', + }, + isList: true, + }; + + const [serviceData, serviceLoaded, serviceError] = + useK8sWatchResource(serviceResource); + + React.useEffect(() => { + if (serviceLoaded && !serviceError && Array.isArray(serviceData)) { + const filteredServices = serviceData.filter((s) => { + const ns = s.metadata?.namespace || ''; + return ( + !ns.startsWith('openshift-') && + ns !== 'kube-system' && + ns !== 'kube-public' && + ns !== 'kube-node-lease' + ); + }); + + setAvailableServices(filteredServices); + } + }, [serviceData, serviceLoaded, serviceError]); + + const handleAddBackendRef = () => { + const newBackendRef: HTTPRouteBackendRef = { + id: `backend-${Date.now().toString(36)}`, + serviceName: '', + serviceNamespace: '', + port: 0, + weight: 1, + }; + + const updatedBackendRefs = [...(currentRule.backendRefs || []), newBackendRef]; + setCurrentRule({ + ...currentRule, + backendRefs: updatedBackendRefs, + }); + + setActiveBackendTab(updatedBackendRefs.length - 1); + }; + + const handleBackendTabSelect = (event: any, tabIndex: number) => { + setActiveBackendTab(tabIndex); + }; + + // Remove backend reference + const handleRemoveBackendRef = (backendIndex: number) => { + const updatedBackendRefs = (currentRule.backendRefs || []).filter((_, i) => i !== backendIndex); + setCurrentRule({ + ...currentRule, + backendRefs: updatedBackendRefs, + }); + + // Adjust active tab + if (activeBackendTab >= updatedBackendRefs.length && updatedBackendRefs.length > 0) { + setActiveBackendTab(updatedBackendRefs.length - 1); + } else if (updatedBackendRefs.length === 0) { + setActiveBackendTab(0); + } + }; + + const handleServiceChange = (backendIndex: number, serviceKey: string) => { + const [serviceName, serviceNamespace] = serviceKey.split(':'); + const selectedService = availableServices.find( + (s) => s.metadata.name === serviceName && s.metadata.namespace === serviceNamespace, + ); + + const updatedBackendRefs = [...(currentRule.backendRefs || [])]; + updatedBackendRefs[backendIndex] = { + ...updatedBackendRefs[backendIndex], + serviceName, + serviceNamespace: selectedService?.metadata.namespace || '', + port: 0, + }; + + setCurrentRule({ + ...currentRule, + backendRefs: updatedBackendRefs, + }); + }; + + const getAvailablePortsForService = (serviceName: string, serviceNamespace: string) => { + if (!serviceName || !serviceNamespace) return []; + + const service = availableServices.find( + (s) => s.metadata.name === serviceName && s.metadata.namespace === serviceNamespace, + ); + + return service?.spec.ports || []; + }; + + // Handle port selection + const handlePortChange = (backendIndex: number, selectedPort: number) => { + const updatedBackendRefs = [...(currentRule.backendRefs || [])]; + updatedBackendRefs[backendIndex] = { + ...updatedBackendRefs[backendIndex], + port: selectedPort, + }; + + setCurrentRule({ + ...currentRule, + backendRefs: updatedBackendRefs, + }); + }; + return ( +
+ {/* Empty state */} + {!currentRule.backendRefs || currentRule.backendRefs.length === 0 ? ( +
+

{t('Backend references')}

+

+ {t( + 'Defines the backend Kubernetes Service(s) to forward traffic to. Traffic is load-balanced between them based on weight. If omitted, this rule will have no effect.', + )} +

+ +
+ ) : ( +
+
+

{t('Backend references')}

+

+ {t( + 'Defines the backend Kubernetes Service(s) to forward traffic to. Traffic is load-balanced between them based on weight.', + )} +

+
+ + + {currentRule.backendRefs.map((backendRef, index) => ( + + {backendRef.serviceName || 'empty'} {'|'} {backendRef.port || 'empty'} {'|'}{' '} + {backendRef.weight || 'empty'} +
+ } + > + Backend-{index + 1} + + } + tabContentId={`backend-content-${index}`} + /> + ))} + + + {/* Tab Contents */} + {currentRule.backendRefs.map((backendRef, index) => ( + + ))} + + )} +
+ ); +}; + +export default BackendReferencesWizardStep; diff --git a/src/components/httproute/backend-refs/backendTypes.ts b/src/components/httproute/backend-refs/backendTypes.ts new file mode 100644 index 0000000..de48652 --- /dev/null +++ b/src/components/httproute/backend-refs/backendTypes.ts @@ -0,0 +1,39 @@ +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; + +export interface HTTPRouteBackendRef { + id: string; + serviceName: string; + serviceNamespace: string; + port: number; + weight: number; +} + +export interface K8sService extends K8sResourceCommon { + metadata: { + name: string; + namespace: string; + }; + spec: { + ports: Array<{ + name?: string; + port: number; + protocol?: string; + targetPort?: number | string; + }>; + }; +} + +export interface BackendReferencesWizardStepProps { + currentRule: { backendRefs?: HTTPRouteBackendRef[] }; + setCurrentRule: (rule: any) => void; + t: (key: string) => string; +} + +export interface HTTPRouteBackendRefSpec { + name: string; + namespace?: string; + port?: number; + weight?: number; + group?: string; + kind?: string; +} diff --git a/src/components/httproute/backend-refs/backendUtils.tsx b/src/components/httproute/backend-refs/backendUtils.tsx new file mode 100644 index 0000000..6f32ef1 --- /dev/null +++ b/src/components/httproute/backend-refs/backendUtils.tsx @@ -0,0 +1,55 @@ +import { HTTPRouteBackendRef, HTTPRouteBackendRefSpec } from './backendTypes'; + +export const generateBackendRefsForYAML = ( + backendRefs: HTTPRouteBackendRef[], +): HTTPRouteBackendRefSpec[] => { + if (!backendRefs || backendRefs.length === 0) { + return []; + } + + return backendRefs + .filter((ref) => ref.serviceName && ref.serviceName.trim() !== '') + .map((ref) => { + const yamlBackendRef: HTTPRouteBackendRefSpec = { + name: ref.serviceName, + ...(ref.port > 0 ? { port: ref.port } : {}), + }; + + if (ref.serviceNamespace && ref.serviceNamespace.trim() !== '') { + yamlBackendRef.namespace = ref.serviceNamespace; + } + + if (ref.weight && ref.weight !== 1) { + yamlBackendRef.weight = ref.weight; + } + + return yamlBackendRef; + }) + .filter(Boolean); +}; + +export const parseBackendRefsFromYAML = (backendRefs: any[]): HTTPRouteBackendRef[] => { + if (!backendRefs || !Array.isArray(backendRefs)) { + return []; + } + + return backendRefs.map((ref, index) => ({ + id: `backend-${Date.now()}-${index}`, + serviceName: ref.name || '', + serviceNamespace: ref.namespace || '', + port: ref.port || 0, + weight: ref.weight || 1, + })); +}; + +export const areBackendRefsValid = (backendRefs: HTTPRouteBackendRef[]): boolean => { + if (!Array.isArray(backendRefs) || backendRefs.length === 0) return true; + + return backendRefs.every( + (backendRef) => + backendRef.serviceName && + backendRef.serviceName !== '' && + backendRef.port && + backendRef.port > 0, + ); +}; diff --git a/src/components/httproute/filters/FilterActions.tsx b/src/components/httproute/filters/FilterActions.tsx new file mode 100644 index 0000000..89753c6 --- /dev/null +++ b/src/components/httproute/filters/FilterActions.tsx @@ -0,0 +1,824 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + ButtonVariant, + Form, + FormGroup, + FormSelect, + FormSelectOption, + MenuToggle, + Radio, + Select, + SelectList, + SelectOption, + Tab, + Tabs, + TabTitleText, + TextInput, + Tooltip, +} from '@patternfly/react-core'; +import { MinusCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import type { + FilterType, + HTTPRouteFilter, + RequestHeaderModifierFilter, + ResponseHeaderModifierFilter, + URLRewriteFilter, + RequestRedirectFilter, + RequestMirrorFilter, + HeaderKV, +} from './filterTypes'; +import { getFilterSummary, createDefaultFilter } from './filterUtils'; + +type FilterActionsProps = { + filters: HTTPRouteFilter[]; + onChange: (filters: HTTPRouteFilter[]) => void; +}; + +const FilterActions: React.FC = ({ filters, onChange }) => { + const { t } = useTranslation('plugin__gateway-api-console-plugin'); + const [activeFilterTab, setActiveFilterTab] = React.useState(0); + const [isFilterTypeOpen, setIsFilterTypeOpen] = React.useState(false); + const [headerRowsByTab, setHeaderRowsByTab] = React.useState< + Record> + >({}); + + const activeFilter = ((filters || [])[activeFilterTab] || null) as HTTPRouteFilter | null; + const isHeaderModifier = + activeFilter?.type === 'RequestHeaderModifier' || + activeFilter?.type === 'ResponseHeaderModifier'; + + React.useEffect(() => { + if (!isHeaderModifier || !activeFilter) return; + if (headerRowsByTab[activeFilterTab]) return; + const hm = + activeFilter.type === 'RequestHeaderModifier' + ? activeFilter.requestHeaderModifier || {} + : activeFilter.responseHeaderModifier || {}; + const initRows: Array<{ action: 'Add' | 'Set' | 'Delete'; name: string; value?: string }> = []; + if (Array.isArray(hm.add)) { + hm.add.forEach(({ name, value }) => initRows.push({ action: 'Add', name, value })); + } + if (Array.isArray(hm.set)) { + hm.set.forEach(({ name, value }) => initRows.push({ action: 'Set', name, value })); + } + if (hm.remove) { + hm.remove.forEach((name: string) => initRows.push({ action: 'Delete', name })); + } + setHeaderRowsByTab((prev) => ({ + ...prev, + [activeFilterTab]: initRows.length > 0 ? initRows : [{ action: 'Add', name: '', value: '' }], + })); + }, [activeFilterTab, isHeaderModifier, activeFilter?.type]); + + const handleAddFilter = () => { + const newFilter = createDefaultFilter('RequestHeaderModifier'); + const updated = [...(filters || []), newFilter]; + onChange(updated); + setActiveFilterTab(updated.length - 1); + }; + + const handleFilterTabSelect = ( + _event: React.MouseEvent | React.KeyboardEvent | unknown, + tabIndex: number, + ) => { + setActiveFilterTab(Number(tabIndex)); + }; + + const handleRemoveFilter = () => { + const idx = activeFilterTab; + const updated = (filters || []).filter((_, i) => i !== idx); + onChange(updated); + setHeaderRowsByTab((prev) => + Object.fromEntries( + Object.entries(prev) + .filter(([k]) => Number(k) !== idx) + .map(([k, v]) => [String(Number(k) > idx ? Number(k) - 1 : Number(k)), v]), + ), + ); + if (activeFilterTab >= updated.length && updated.length > 0) { + setActiveFilterTab(updated.length - 1); + } else if (updated.length === 0) { + setActiveFilterTab(0); + } + }; + + const handleFilterChangeAt = ( + index: number, + updatedPartial: + | Partial> + | Partial> + | Partial> + | Partial> + | Partial> + | undefined, + ) => { + const copy = [...(filters || [])]; + const current = copy[index] as HTTPRouteFilter; + let updated: HTTPRouteFilter = current; + if (current.type === 'RequestHeaderModifier') { + updated = { + ...current, + requestHeaderModifier: { + ...(current.requestHeaderModifier || {}), + ...(updatedPartial || {}), + }, + } as HTTPRouteFilter; + } else if (current.type === 'ResponseHeaderModifier') { + updated = { + ...current, + responseHeaderModifier: { + ...(current.responseHeaderModifier || {}), + ...(updatedPartial || {}), + }, + } as HTTPRouteFilter; + } else if (current.type === 'URLRewrite') { + updated = { + ...current, + urlRewrite: { ...(current.urlRewrite || {}), ...(updatedPartial || {}) }, + } as HTTPRouteFilter; + } else if (current.type === 'RequestRedirect') { + updated = { + ...current, + requestRedirect: { ...(current.requestRedirect || {}), ...(updatedPartial || {}) }, + } as HTTPRouteFilter; + } else if (current.type === 'RequestMirror') { + updated = { + ...current, + requestMirror: { ...(current.requestMirror || {}), ...(updatedPartial || {}) }, + } as HTTPRouteFilter; + } + copy[index] = updated; + onChange(copy); + }; + + const handleReplaceFilterType = (index: number, newType: FilterType) => { + const replaced = createDefaultFilter(newType as HTTPRouteFilter['type']); + const copy = [...(filters || [])]; + copy[index] = replaced; + onChange(copy); + setHeaderRowsByTab((prev) => { + const next = { ...prev } as typeof prev; + delete next[index]; + return next; + }); + }; + + const filterTypeOptions = [ + { + value: 'RequestHeaderModifier', + label: t('Request Header Modifier'), + description: t('Add, set, or remove request headers.'), + }, + { + value: 'ResponseHeaderModifier', + label: t('Response Header Modifier'), + description: t('Add, set, or remove response headers.'), + }, + { + value: 'RequestRedirect', + label: t('Request Redirect'), + description: t('Redirect the request to a different hostname, path, or port.'), + }, + { + value: 'URLRewrite', + label: t('URL Rewrite'), + description: t('Rewrite the hostname or path of the request before forwarding.'), + }, + { + value: 'RequestMirror', + label: t('Request Mirror'), + description: t('Send a copy of the request to a different backend (for traffic shadowing).'), + }, + ]; + + return ( +
+ + {(filters?.length ?? 0) === 0 ? ( +
+

{t('Filters')}

+

+ {t( + 'A list of actions to perform on a request or response before it is sent to the backend.', + )} +

+ +
+ ) : ( + <> +

{t('Filters')}

+

+ {t( + 'A list of actions to perform on a request or response before it is sent to the backend.', + )} +

+
+ + {(filters || []).map((filter, idx) => ( + + {`Filter-${idx + 1}`} + + } + /> + ))} + +
+ + {(filters || [])[activeFilterTab] && ( +
+
+ + + +
+ +
+
+ + {(() => { + const filter = (filters || [])[activeFilterTab] as HTTPRouteFilter | undefined; + if (!filter) return null; + + if ( + filter.type === 'RequestHeaderModifier' || + filter.type === 'ResponseHeaderModifier' + ) { + return ( + <> +
+
+ {t('Type')} +
+
+ {t('Header name')} +
+
+ {t('Value')} +
+
+
+ {(() => { + const initRows: Array<{ + action: 'Add' | 'Set' | 'Delete'; + name: string; + value?: string; + }> = []; + const hm = + filter.type === 'RequestHeaderModifier' + ? filter.requestHeaderModifier || {} + : filter.responseHeaderModifier || {}; + if (Array.isArray(hm.add)) { + (hm.add as HeaderKV[]).forEach(({ name, value }) => + initRows.push({ action: 'Add', name, value }), + ); + } + if (Array.isArray(hm.set)) { + (hm.set as HeaderKV[]).forEach(({ name, value }) => + initRows.push({ action: 'Set', name, value }), + ); + } + if (hm.remove) { + (hm.remove as string[]).forEach((name) => + initRows.push({ + action: 'Delete', + name, + }), + ); + } + const rows = + headerRowsByTab[activeFilterTab] || + (initRows.length > 0 + ? initRows + : [{ action: 'Add', name: '', value: '' }]); + + const commitRowsToSpec = ( + all: Array<{ + action: 'Add' | 'Set' | 'Delete'; + name: string; + value?: string; + }>, + ) => { + const add: Array<{ name: string; value: string }> = []; + const set: Array<{ name: string; value: string }> = []; + const remove: string[] = []; + all.forEach((r) => { + const name = (r.name || '').trim(); + if (!name) return; + if (r.action === 'Delete') remove.push(name); + if (r.action === 'Add' && r.value) add.push({ name, value: r.value }); + if (r.action === 'Set' && r.value) set.push({ name, value: r.value }); + }); + handleFilterChangeAt( + activeFilterTab, + filter.type === 'RequestHeaderModifier' + ? { add, set, remove } + : { add, set, remove }, + ); + }; + + return rows.map((op, idx: number) => ( +
+ { + const updatedAction = value as 'Add' | 'Set' | 'Delete'; + const next = { ...op, action: updatedAction } as { + action: 'Add' | 'Set' | 'Delete'; + name: string; + value?: string; + }; + if (updatedAction === 'Delete') next.value = ''; + const all = rows.map((r, i) => (i === idx ? next : r)); + setHeaderRowsByTab((prev) => ({ + ...prev, + [activeFilterTab]: all, + })); + commitRowsToSpec(all); + }} + aria-label={t('Select header action')} + > + + + + + { + const next = { ...op, name: value } as { + action: 'Add' | 'Set' | 'Delete'; + name: string; + value?: string; + }; + const all = rows.map((r, i) => (i === idx ? next : r)); + setHeaderRowsByTab((prev) => ({ + ...prev, + [activeFilterTab]: all, + })); + commitRowsToSpec(all); + }} + placeholder={op.action === 'Add' ? 'x-Request-ID' : 'Content-type'} + /> + {op.action !== 'Delete' ? ( + { + const next = { ...op, value } as { + action: 'Add' | 'Set' | 'Delete'; + name: string; + value?: string; + }; + const all = rows.map((r, i) => (i === idx ? next : r)); + setHeaderRowsByTab((prev) => ({ + ...prev, + [activeFilterTab]: all, + })); + commitRowsToSpec(all); + }} + placeholder={op.action === 'Add' ? '{UUID}' : 'application/json'} + /> + ) : ( +
+ )} + +
+ )); + })()} + + + ); + } + + if (filter.type === 'URLRewrite') { + return ( + <> +
+ + + handleFilterChangeAt(activeFilterTab, { hostname: value }) + } + placeholder={'elsewhere.example'} + /> + +
+
+ + { + const type = value as 'ReplaceFullPath' | 'ReplacePrefixMatch'; + const v = + filter.urlRewrite?.path?.replaceFullPath || + filter.urlRewrite?.path?.replacePrefixMatch || + ''; + handleFilterChangeAt(activeFilterTab, { + path: + type === 'ReplaceFullPath' + ? { type, replaceFullPath: v } + : { type, replacePrefixMatch: v }, + }); + }} + > + + + + + + { + const type = filter.urlRewrite?.path?.type || 'ReplaceFullPath'; + handleFilterChangeAt( + activeFilterTab, + type === 'ReplaceFullPath' + ? { path: { type, replaceFullPath: value } } + : { path: { type, replacePrefixMatch: value } }, + ); + }} + placeholder={'/v2/$1'} + /> + +
+ + ); + } + + if (filter.type === 'RequestRedirect') { + const f = filter; + return ( + <> +
+ +
+ + handleFilterChangeAt(activeFilterTab, { path: undefined }) + } + /> + + handleFilterChangeAt(activeFilterTab, { + path: { type: 'ReplaceFullPath', replaceFullPath: '' }, + }) + } + /> +
+
+ + {!f.requestRedirect?.path ? ( + <> + + + handleFilterChangeAt(activeFilterTab, { scheme: value }) + } + > + + + + + + ) : ( + <> +
+ + { + const type = value as + | 'ReplaceFullPath' + | 'ReplacePrefixMatch'; + const v = + f.requestRedirect?.path?.replaceFullPath || + f.requestRedirect?.path?.replacePrefixMatch || + ''; + handleFilterChangeAt(activeFilterTab, { + path: + type === 'ReplaceFullPath' + ? { type, replaceFullPath: v } + : { type, replacePrefixMatch: v }, + }); + }} + > + + + + + + { + const type = + f.requestRedirect?.path?.type || 'ReplaceFullPath'; + handleFilterChangeAt( + activeFilterTab, + type === 'ReplaceFullPath' + ? { path: { type, replaceFullPath: value } } + : { path: { type, replacePrefixMatch: value } }, + ); + }} + placeholder={'/new-path or /prefix'} + /> + +
+ + )} +
+ + + handleFilterChangeAt(activeFilterTab, { hostname: value }) + } + placeholder={t('example.com')} + /> + + + { + const portNum = value ? Number(value) : undefined; + handleFilterChangeAt(activeFilterTab, { port: portNum }); + }} + placeholder={'8080'} + /> + + + + handleFilterChangeAt(activeFilterTab, { + statusCode: Number(value), + }) + } + > + + + + +
+
+ + ); + } + + if (filter.type === 'RequestMirror') { + const f = filter; + return ( + <> +
+ + + handleFilterChangeAt(activeFilterTab, { + backendRef: { + ...(f.requestMirror?.backendRef || {}), + name: value, + }, + }) + } + placeholder={'foo-v2'} + /> + + + { + const portNum = value ? Number(value) : undefined; + handleFilterChangeAt(activeFilterTab, { + backendRef: { + name: f.requestMirror?.backendRef?.name || '', + port: portNum, + }, + }); + }} + placeholder={'8080'} + /> + +
+ + ); + } + + return null; + })()} +
+ )} + + )} + + + ); +}; + +export default FilterActions; diff --git a/src/components/httproute/filters/filterTypes.tsx b/src/components/httproute/filters/filterTypes.tsx new file mode 100644 index 0000000..c46cb45 --- /dev/null +++ b/src/components/httproute/filters/filterTypes.tsx @@ -0,0 +1,80 @@ +export interface HeaderOperation { + action: 'Add' | 'Set' | 'Delete'; + name: string; + value?: string; +} + +export type FilterType = + | 'RequestHeaderModifier' + | 'ResponseHeaderModifier' + | 'URLRewrite' + | 'RequestRedirect' + | 'RequestMirror'; + +export interface HeaderKV { + id?: string; + name: string; + value: string; +} + +export interface HeaderNameOnly { + id?: string; + name: string; +} + +export interface RequestHeaderModifierFilter { + type: 'RequestHeaderModifier'; + requestHeaderModifier?: { + add?: HeaderKV[]; + set?: HeaderKV[]; + remove?: Array; + }; +} + +export interface ResponseHeaderModifierFilter { + type: 'ResponseHeaderModifier'; + responseHeaderModifier?: { + add?: HeaderKV[]; + set?: HeaderKV[]; + remove?: Array; + }; +} + +export interface RequestRedirectFilter { + type: 'RequestRedirect'; + requestRedirect: { + scheme?: string; + hostname?: string; + port?: number; + statusCode?: number; + path?: { + type: 'ReplaceFullPath' | 'ReplacePrefixMatch'; + replaceFullPath?: string; + replacePrefixMatch?: string; + }; + }; +} + +export interface RequestMirrorFilter { + type: 'RequestMirror'; + requestMirror: { backendRef: { name: string; port?: number } }; +} + +export interface URLRewriteFilter { + type: 'URLRewrite'; + urlRewrite: { + hostname?: string; + path?: { + type: 'ReplaceFullPath' | 'ReplacePrefixMatch'; + replaceFullPath?: string; + replacePrefixMatch?: string; + }; + }; +} + +export type HTTPRouteFilter = + | RequestHeaderModifierFilter + | ResponseHeaderModifierFilter + | RequestRedirectFilter + | RequestMirrorFilter + | URLRewriteFilter; diff --git a/src/components/httproute/filters/filterUtils.tsx b/src/components/httproute/filters/filterUtils.tsx new file mode 100644 index 0000000..e74d614 --- /dev/null +++ b/src/components/httproute/filters/filterUtils.tsx @@ -0,0 +1,400 @@ +import type { + HTTPRouteFilter, + RequestHeaderModifierFilter, + ResponseHeaderModifierFilter, + URLRewriteFilter, + RequestRedirectFilter, + RequestMirrorFilter, + HeaderKV, + HeaderNameOnly, +} from './filterTypes'; + +export const createDefaultFilter = (type: HTTPRouteFilter['type']): HTTPRouteFilter => { + switch (type) { + case 'RequestHeaderModifier': + return { type: 'RequestHeaderModifier', requestHeaderModifier: {} }; + case 'ResponseHeaderModifier': + return { type: 'ResponseHeaderModifier', responseHeaderModifier: {} }; + case 'URLRewrite': + return { type: 'URLRewrite', urlRewrite: {} }; + case 'RequestRedirect': + return { type: 'RequestRedirect', requestRedirect: { scheme: 'https', statusCode: 301 } }; + case 'RequestMirror': + return { type: 'RequestMirror', requestMirror: { backendRef: { name: '' } } }; + default: + return { type: 'RequestHeaderModifier', requestHeaderModifier: {} }; + } +}; + +export const generateFiltersForYAML = (filters: HTTPRouteFilter[]): HTTPRouteFilter[] => { + if (!Array.isArray(filters) || filters.length === 0) return []; + return filters + .map((f) => { + switch (f.type) { + case 'RequestHeaderModifier': { + const hm = f.requestHeaderModifier || {}; + const add = ((hm.add || []) as Array<{ id?: string; name: string; value: string }>) + .filter((i) => (i.name || '').trim() && (i.value || '').trim()) + .map((i) => ({ name: i.name, value: i.value })); + const set = ((hm.set || []) as Array<{ id?: string; name: string; value: string }>) + .filter((i) => (i.name || '').trim() && (i.value || '').trim()) + .map((i) => ({ name: i.name, value: i.value })); + const remove = ((hm.remove || []) as Array) + .map((e) => (typeof e === 'string' ? e : e.name)) + .filter((s) => (s || '').trim()); + const next: RequestHeaderModifierFilter = { type: 'RequestHeaderModifier' } as const; + if (add.length || set.length || remove.length) { + const requestHeaderModifier: NonNullable< + RequestHeaderModifierFilter['requestHeaderModifier'] + > = {}; + if (add.length) requestHeaderModifier.add = add; + if (set.length) requestHeaderModifier.set = set; + if (remove.length) requestHeaderModifier.remove = remove; + (next as RequestHeaderModifierFilter).requestHeaderModifier = requestHeaderModifier; + } + return next as HTTPRouteFilter; + } + case 'ResponseHeaderModifier': { + const hm = f.responseHeaderModifier || {}; + const add = ((hm.add || []) as Array<{ id?: string; name: string; value: string }>) + .filter((i) => (i.name || '').trim() && (i.value || '').trim()) + .map((i) => ({ name: i.name, value: i.value })); + const set = ((hm.set || []) as Array<{ id?: string; name: string; value: string }>) + .filter((i) => (i.name || '').trim() && (i.value || '').trim()) + .map((i) => ({ name: i.name, value: i.value })); + const remove = ((hm.remove || []) as Array) + .map((e) => (typeof e === 'string' ? e : e.name)) + .filter((s) => (s || '').trim()); + const next: ResponseHeaderModifierFilter = { type: 'ResponseHeaderModifier' } as const; + if (add.length || set.length || remove.length) { + const responseHeaderModifier: NonNullable< + ResponseHeaderModifierFilter['responseHeaderModifier'] + > = {}; + if (add.length) responseHeaderModifier.add = add; + if (set.length) responseHeaderModifier.set = set; + if (remove.length) responseHeaderModifier.remove = remove; + (next as ResponseHeaderModifierFilter).responseHeaderModifier = responseHeaderModifier; + } + return next as HTTPRouteFilter; + } + case 'RequestRedirect': { + const rr = f.requestRedirect || {}; + const next: RequestRedirectFilter = { + type: 'RequestRedirect', + requestRedirect: {}, + } as const; + const scheme = (rr.scheme || '').trim(); + const hostname = (rr.hostname || '').trim(); + const port = typeof rr.port === 'number' ? rr.port : undefined; + const statusCode = typeof rr.statusCode === 'number' ? rr.statusCode : undefined; + if (scheme) next.requestRedirect.scheme = rr.scheme; + if (hostname) next.requestRedirect.hostname = rr.hostname; + if (port !== undefined) next.requestRedirect.port = port; + if (statusCode !== undefined) next.requestRedirect.statusCode = statusCode; + const path = rr.path || undefined; + if (path && (path.type === 'ReplaceFullPath' || path.type === 'ReplacePrefixMatch')) { + if (path.type === 'ReplaceFullPath' && (path.replaceFullPath || '').trim()) { + next.requestRedirect.path = { + type: 'ReplaceFullPath', + replaceFullPath: path.replaceFullPath, + }; + } + if (path.type === 'ReplacePrefixMatch' && (path.replacePrefixMatch || '').trim()) { + next.requestRedirect.path = { + type: 'ReplacePrefixMatch', + replacePrefixMatch: path.replacePrefixMatch, + }; + } + } + if (Object.keys(next.requestRedirect).length === 0) { + return { type: 'RequestRedirect' } as HTTPRouteFilter; + } + return next as HTTPRouteFilter; + } + case 'URLRewrite': { + const url = f.urlRewrite || {}; + const next: URLRewriteFilter = { type: 'URLRewrite', urlRewrite: {} } as URLRewriteFilter; + const hostname = (url.hostname || '').trim(); + const path = url.path || undefined; + const urlRewrite: URLRewriteFilter['urlRewrite'] = {}; + if (hostname) urlRewrite.hostname = url.hostname; + if (path && (path.type === 'ReplaceFullPath' || path.type === 'ReplacePrefixMatch')) { + if (path.type === 'ReplaceFullPath' && (path.replaceFullPath || '').trim()) { + urlRewrite.path = { + type: 'ReplaceFullPath', + replaceFullPath: path.replaceFullPath, + }; + } + if (path.type === 'ReplacePrefixMatch' && (path.replacePrefixMatch || '').trim()) { + urlRewrite.path = { + type: 'ReplacePrefixMatch', + replacePrefixMatch: path.replacePrefixMatch, + }; + } + } + if (Object.keys(urlRewrite).length > 0) next.urlRewrite = urlRewrite; + return next as HTTPRouteFilter; + } + case 'RequestMirror': { + const rm = f.requestMirror || { backendRef: {} }; + const backendRef = (rm.backendRef || {}) as { name?: string; port?: number }; + const name = (backendRef.name || '').trim(); + const port = typeof backendRef.port === 'number' ? backendRef.port : undefined; + const next: RequestMirrorFilter = { + type: 'RequestMirror', + requestMirror: { backendRef: { name: '' } }, + } as RequestMirrorFilter; + if (name) { + next.requestMirror = { backendRef: { name } }; + if (port !== undefined) next.requestMirror.backendRef.port = port; + } + return next as HTTPRouteFilter; + } + default: + return f; + } + }) + .filter(Boolean) as HTTPRouteFilter[]; +}; + +export const getFilterSummary = (filter: HTTPRouteFilter) => { + if (!filter) return ''; + switch (filter.type) { + case 'RequestHeaderModifier': + case 'ResponseHeaderModifier': { + const hm = + filter.type === 'RequestHeaderModifier' + ? filter.requestHeaderModifier || {} + : filter.responseHeaderModifier || {}; + + const parts: string[] = []; + + if (Array.isArray(hm.add) && hm.add.length > 0) { + hm.add.forEach((header: any) => { + if (header.name && header.value) { + parts.push(`Type: add | Header name: ${header.name} | Value: ${header.value}`); + } + }); + } + + if (Array.isArray(hm.set) && hm.set.length > 0) { + hm.set.forEach((header: any) => { + if (header.name && header.value) { + parts.push(`Type: set | Header name: ${header.name} | Value: ${header.value}`); + } + }); + } + + if (Array.isArray(hm.remove) && hm.remove.length > 0) { + hm.remove.forEach((headerName: any) => { + const name = typeof headerName === 'string' ? headerName : headerName?.name; + if (name) { + parts.push(`Type: remove | Header name: ${name}`); + } + }); + } + + return parts.length ? `${filter.type} — ${parts.join(' | ')}` : filter.type; + } + case 'URLRewrite': { + const f = filter.urlRewrite || {}; + const parts: string[] = []; + if (f.hostname) parts.push(`host → ${f.hostname}`); + if (f.path?.type === 'ReplaceFullPath' && f.path.replaceFullPath) + parts.push(`ReplaceFullPath → ${f.path.replaceFullPath}`); + if (f.path?.type === 'ReplacePrefixMatch' && f.path.replacePrefixMatch) + parts.push(`ReplacePrefixMatch → ${f.path.replacePrefixMatch}`); + return parts.length ? `${filter.type} — ${parts.join(' | ')}` : filter.type; + } + case 'RequestRedirect': { + const rr = filter.requestRedirect || {}; + const parts = [ + rr.scheme, + rr.hostname, + rr.port?.toString?.(), + rr.statusCode?.toString?.(), + rr.path?.type, + rr.path?.replaceFullPath, + rr.path?.replacePrefixMatch, + ].filter(Boolean) as string[]; + return parts.length ? `${filter.type} — ${parts.join(' | ')}` : filter.type; + } + case 'RequestMirror': { + const rm = filter.requestMirror || { backendRef: { name: '' } }; + const parts = [rm.backendRef.name, rm.backendRef.port?.toString?.()].filter( + Boolean, + ) as string[]; + return `${filter.type} — ${parts.join(' | ')}`; + } + default: + return 'Filter'; + } +}; + +export const parseFiltersFromYAML = (filters: HTTPRouteFilter[] | undefined): HTTPRouteFilter[] => { + if (!Array.isArray(filters) || filters.length === 0) return []; + return filters.map((f, fi) => { + if (!f || !('type' in f)) return f as HTTPRouteFilter; + switch (f.type) { + case 'RequestHeaderModifier': { + const hm: NonNullable = + (f as RequestHeaderModifierFilter).requestHeaderModifier || {}; + const add: HeaderKV[] = Array.isArray(hm.add) + ? (hm.add as HeaderKV[]).map((i, iIdx) => ({ + id: i.id || `add-${fi}-${iIdx}-${Date.now()}`, + name: i.name || '', + value: i.value || '', + })) + : []; + const set: HeaderKV[] = Array.isArray(hm.set) + ? (hm.set as HeaderKV[]).map((i, iIdx) => ({ + id: i.id || `set-${fi}-${iIdx}-${Date.now()}`, + name: i.name || '', + value: i.value || '', + })) + : []; + const remove: string[] = Array.isArray(hm.remove) + ? (hm.remove as Array) + .map((e) => (typeof e === 'string' ? e : e?.name || '')) + .filter((s) => s.trim()) + : []; + const next: RequestHeaderModifierFilter = { + type: 'RequestHeaderModifier', + requestHeaderModifier: { add, set, remove }, + }; + return next; + } + case 'ResponseHeaderModifier': { + const hm: NonNullable = + (f as ResponseHeaderModifierFilter).responseHeaderModifier || {}; + const add: HeaderKV[] = Array.isArray(hm.add) + ? (hm.add as HeaderKV[]).map((i, iIdx) => ({ + id: i.id || `add-${fi}-${iIdx}-${Date.now()}`, + name: i.name || '', + value: i.value || '', + })) + : []; + const set: HeaderKV[] = Array.isArray(hm.set) + ? (hm.set as HeaderKV[]).map((i, iIdx) => ({ + id: i.id || `set-${fi}-${iIdx}-${Date.now()}`, + name: i.name || '', + value: i.value || '', + })) + : []; + const remove: string[] = Array.isArray(hm.remove) + ? (hm.remove as Array) + .map((e) => (typeof e === 'string' ? e : e?.name || '')) + .filter((s) => s.trim()) + : []; + const next: ResponseHeaderModifierFilter = { + type: 'ResponseHeaderModifier', + responseHeaderModifier: { add, set, remove }, + }; + return next; + } + case 'RequestRedirect': { + const rr = (f as RequestRedirectFilter).requestRedirect || {}; + const obj: NonNullable = {}; + if (typeof rr.scheme === 'string') obj.scheme = rr.scheme; + if (typeof rr.hostname === 'string') obj.hostname = rr.hostname; + if (typeof rr.port === 'number') obj.port = rr.port; + if (typeof rr.statusCode === 'number') obj.statusCode = rr.statusCode; + if ( + rr.path && + (rr.path.type === 'ReplaceFullPath' || rr.path.type === 'ReplacePrefixMatch') + ) { + if (rr.path.type === 'ReplaceFullPath') { + obj.path = { type: 'ReplaceFullPath', replaceFullPath: rr.path.replaceFullPath || '' }; + } else { + obj.path = { + type: 'ReplacePrefixMatch', + replacePrefixMatch: rr.path.replacePrefixMatch || '', + }; + } + } + const next: RequestRedirectFilter = { type: 'RequestRedirect', requestRedirect: obj }; + return next; + } + case 'URLRewrite': { + const url = (f as URLRewriteFilter).urlRewrite || {}; + const obj: NonNullable = {}; + if (typeof url.hostname === 'string') obj.hostname = url.hostname; + if ( + url.path && + (url.path.type === 'ReplaceFullPath' || url.path.type === 'ReplacePrefixMatch') + ) { + if (url.path.type === 'ReplaceFullPath') { + obj.path = { type: 'ReplaceFullPath', replaceFullPath: url.path.replaceFullPath || '' }; + } else { + obj.path = { + type: 'ReplacePrefixMatch', + replacePrefixMatch: url.path.replacePrefixMatch || '', + }; + } + } + const next: URLRewriteFilter = { type: 'URLRewrite', urlRewrite: obj }; + return next; + } + case 'RequestMirror': { + const rm = (f as RequestMirrorFilter).requestMirror || { backendRef: { name: '' } }; + const backendRef = rm.backendRef || { name: '' }; + const next: RequestMirrorFilter = { + type: 'RequestMirror', + requestMirror: { backendRef: { name: backendRef.name || '' } }, + }; + if (typeof backendRef.port === 'number') + next.requestMirror.backendRef.port = backendRef.port; + return next; + } + default: + return f as HTTPRouteFilter; + } + }); +}; + +export const isFilterConfigValid = (f: HTTPRouteFilter): boolean => { + switch (f.type) { + case 'RequestHeaderModifier': { + const hm = f.requestHeaderModifier || {}; + return Boolean( + (hm.add && Object.keys(hm.add).length) || + (hm.set && Object.keys(hm.set).length) || + (hm.remove && hm.remove.length), + ); + } + case 'ResponseHeaderModifier': { + const hm = f.responseHeaderModifier || {}; + return Boolean( + (hm.add && Object.keys(hm.add).length) || + (hm.set && Object.keys(hm.set).length) || + (hm.remove && hm.remove.length), + ); + } + case 'RequestRedirect': { + const rr = f.requestRedirect || {}; + const hasHostOrPort = Boolean((rr.hostname || '').trim?.() || rr.port); + if (!rr.path) { + return Boolean((rr.scheme || '').trim?.() || hasHostOrPort || rr.statusCode); + } + return Boolean(rr.path?.type && (rr.path.replaceFullPath || rr.path.replacePrefixMatch)); + } + case 'URLRewrite': { + const url = f.urlRewrite || {}; + return Boolean( + (url.hostname || '').trim?.() || + (url.path?.type && (url.path.replaceFullPath || url.path.replacePrefixMatch)), + ); + } + case 'RequestMirror': { + const rm = f.requestMirror || { backendRef: { name: '' } }; + return (rm.backendRef.name || '').trim().length > 0; + } + default: + return true; + } +}; + +export const validateFiltersStep = (filters: HTTPRouteFilter[]) => { + if (!Array.isArray(filters) || filters.length === 0) return true; + return filters.every(isFilterConfigValid); +}; diff --git a/src/components/httproute/review/ReviewStep.tsx b/src/components/httproute/review/ReviewStep.tsx new file mode 100644 index 0000000..cca8ee2 --- /dev/null +++ b/src/components/httproute/review/ReviewStep.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { + Form, + Title, + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, + Alert, + AlertVariant, + AlertActionLink, + ExpandableSection, + List, + ListItem, + useWizardContext, +} from '@patternfly/react-core'; +import { validateCompleteRule } from './reviewValidation'; +import { getFilterSummary } from '../filters/filterUtils'; + +interface ReviewStepProps { + currentRule: any; + t: (key: string) => string; +} + +const ReviewStep: React.FC = ({ currentRule, t }) => { + const hasMatches = currentRule.matches?.length > 0; + const hasFilters = currentRule.filters?.length > 0; + const hasBackendRefs = currentRule.backendRefs?.length > 0; + const validationResult = React.useMemo(() => { + return validateCompleteRule(currentRule); + }, [currentRule]); + + const isRuleValid = validationResult.isValid; + const { goToStepById } = useWizardContext(); + const formatFilterSummary = (filter: any): React.ReactNode => { + const summary = getFilterSummary(filter); + + if (!summary || summary === 'Filter') { + return
{filter.type || 'Unknown Filter'}
; + } + + const parts = summary.split(' — '); + const filterName = parts[0]; // "RequestHeaderModifier" + const details = parts[1]; + + return ( +
+
{filterName}
+ + {details && ( +
+ {details.split(' | Type:').map((detail, idx) => { + const formattedDetail = idx === 0 ? detail : `Type:${detail}`; + + return ( +
+ {formattedDetail} +
+ ); + })} +
+ )} +
+ ); + }; + + return ( +
+ {t('Review and create')} + + {!isRuleValid && ( + + goToStepById?.('rule-step-0')}> + {t('Restart configuration')} + + + {t('Learn more')} + + + } + > +

+ {validationResult.errors.length > 0 + ? t('This rule has validation errors that must be fixed before creation.') + : t( + 'This rule cannot be created until it includes at least one match, filter, or backend reference.', + )} +

+ {validationResult.errors.length > 0 && ( + + + {validationResult.errors.map((error, index) => ( + {error.message} + ))} + + + )} +
+ )} + {validationResult.warnings.length > 0 && validationResult.errors.length === 0 && ( + + + + {validationResult.warnings.map((warning, index) => ( + {warning.message} + ))} + + + + )} + + + {/* Rule ID */} + + {t('Rule ID')} + {currentRule.id} + + + {/* Matches */} + + {t('Matches')} + + {hasMatches ? ( +
+ {currentRule.matches.map((match, index) => ( +
+ {match.pathType || 'PathPrefix'} | {match.pathValue || '/'} |{' '} + {match.method || 'GET'} +
+ ))} +
+ ) : ( + + )} +
+
+ + {/* Filters */} + + {t('Filters')} + + {hasFilters ? ( +
+ {currentRule.filters.map((filter, index) => ( +
+ {formatFilterSummary(filter)} +
+ ))} +
+ ) : ( + + )} +
+
+ + {/* Backend Service */} + + {t('Backend Service')} + + {hasBackendRefs ? ( +
+ {currentRule.backendRefs.map((backend, index) => ( +
+ {backend.serviceName} | {backend.port} | {`${backend.weight} weight`} +
+ ))} +
+ ) : ( + + )} +
+
+
+
+ ); +}; + +export default ReviewStep; diff --git a/src/components/httproute/review/reviewValidation.tsx b/src/components/httproute/review/reviewValidation.tsx new file mode 100644 index 0000000..bfbc246 --- /dev/null +++ b/src/components/httproute/review/reviewValidation.tsx @@ -0,0 +1,804 @@ +import { HTTPRouteMatch } from '../HTTPRouteModel'; + +export interface ValidationError { + field: string; + message: string; + severity: 'error' | 'warning'; +} + +export interface ValidationResult { + isValid: boolean; + errors: ValidationError[]; + warnings: ValidationError[]; +} + +// generic interface for headers and query params +interface HTTPRouteParameter { + id: string; + type: string; + name: string; + value: string; +} + +// utility function for number validation +export const validateNumberField = ( + value: any, + fieldName: string, + min: number, + max: number, + isRequired = true, +): ValidationError | null => { + // check if required + if (isRequired && (value === undefined || value === null || value === '')) { + return { + field: fieldName, + message: `${fieldName} is required`, + severity: 'error', + }; + } + + // skip validation if not required and empty + if (!isRequired && (value === undefined || value === null || value === '')) { + return null; + } + + // convert string to number if needed + const numValue = typeof value === 'string' ? parseInt(value, 10) : value; + + // check if it's a valid number + if (typeof numValue !== 'number' || isNaN(numValue) || !Number.isInteger(numValue)) { + return { + field: fieldName, + message: `${fieldName} must be a valid integer`, + severity: 'error', + }; + } + + // check range + if (numValue < min || numValue > max) { + return { + field: fieldName, + message: `${fieldName} must be between ${min} and ${max}`, + severity: 'error', + }; + } + + return null; +}; + +// generic validation for headers and query parameters +export const validateHTTPRouteParameters = ( + parameters: HTTPRouteParameter[], + matchIndex: number, + parameterType: 'headers' | 'queryParams', +): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + const displayName = parameterType === 'headers' ? 'Header' : 'Query Parameter'; + const fieldPrefix = parameterType; + + parameters.forEach((param, paramIndex) => { + // name - required field + if (!param.name || param.name.trim() === '') { + errors.push({ + field: `matches[${matchIndex}].${fieldPrefix}[${paramIndex}].name`, + message: `Match ${matchIndex + 1}, ${displayName} ${paramIndex + 1}: Name is required`, + severity: 'error', + }); + } else { + // additional validation for headers (only for headers) + if (parameterType === 'headers') { + const validHeaderName = /^[a-zA-Z0-9\-_]+$/.test(param.name); + if (!validHeaderName) { + errors.push({ + field: `matches[${matchIndex}].${fieldPrefix}[${paramIndex}].name`, + message: `Match ${matchIndex + 1}, ${displayName} ${ + paramIndex + 1 + }: Invalid header name format`, + severity: 'error', + }); + } + } + } + + // value - required field + if (!param.value || param.value.trim() === '') { + errors.push({ + field: `matches[${matchIndex}].${fieldPrefix}[${paramIndex}].value`, + message: `Match ${matchIndex + 1}, ${displayName} ${paramIndex + 1}: Value is required`, + severity: 'error', + }); + } + + // type - must be valid + if (!param.type || !['Exact', 'RegularExpression'].includes(param.type)) { + errors.push({ + field: `matches[${matchIndex}].${fieldPrefix}[${paramIndex}].type`, + message: `Match ${matchIndex + 1}, ${displayName} ${ + paramIndex + 1 + }: Type must be 'Exact' or 'RegularExpression'`, + severity: 'error', + }); + } + + // regexp validation if type is regularexpression + if (param.type === 'RegularExpression' && param.value) { + try { + new RegExp(param.value); + } catch (e) { + errors.push({ + field: `matches[${matchIndex}].${fieldPrefix}[${paramIndex}].value`, + message: `Match ${matchIndex + 1}, ${displayName} ${ + paramIndex + 1 + }: Invalid regular expression`, + severity: 'error', + }); + } + } + }); + + // check for duplicate names + const paramNames = parameters.map((p) => p.name.toLowerCase()).filter(Boolean); + const duplicateNames = paramNames.filter((name, index) => paramNames.indexOf(name) !== index); + if (duplicateNames.length > 0) { + warnings.push({ + field: `matches[${matchIndex}].${fieldPrefix}`, + message: `Match ${matchIndex + 1}: Duplicate ${ + parameterType === 'headers' ? 'header' : 'query parameter' + } names: ${[...new Set(duplicateNames)].join(', ')}`, + severity: 'warning', + }); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +}; + +// matches validation - uses generic function +export const validateMatches = (matches: HTTPRouteMatch[]): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + if (matches.length === 0) { + return { isValid: true, errors, warnings }; + } + + matches.forEach((match, matchIndex) => { + // path type - required field + if (!match.pathType || match.pathType.trim() === '') { + errors.push({ + field: `matches[${matchIndex}].pathType`, + message: `Match ${matchIndex + 1}: Path type is required`, + severity: 'error', + }); + } + + // path value - must be valid + if (!match.pathValue || match.pathValue.trim() === '') { + errors.push({ + field: `matches[${matchIndex}].pathValue`, + message: `Match ${matchIndex + 1}: Path value is required`, + severity: 'error', + }); + } else if (!match.pathValue.startsWith('/')) { + errors.push({ + field: `matches[${matchIndex}].pathValue`, + message: `Match ${matchIndex + 1}: Path must start with '/'`, + severity: 'error', + }); + } + + // method - required field + if (!match.method || match.method.trim() === '') { + errors.push({ + field: `matches[${matchIndex}].method`, + message: `Match ${matchIndex + 1}: HTTP method is required`, + severity: 'error', + }); + } + + // headers validation via generic function + if (match.headers && match.headers.length > 0) { + const headerValidation = validateHTTPRouteParameters( + match.headers as HTTPRouteParameter[], + matchIndex, + 'headers', + ); + errors.push(...headerValidation.errors); + warnings.push(...headerValidation.warnings); + } + + // query parameters validation via generic function + if (match.queryParams && match.queryParams.length > 0) { + const queryParamValidation = validateHTTPRouteParameters( + match.queryParams as HTTPRouteParameter[], + matchIndex, + 'queryParams', + ); + errors.push(...queryParamValidation.errors); + warnings.push(...queryParamValidation.warnings); + } + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +}; + +// backend references validation - for real backend refs +export const validateBackendRefs = (backendRefs: any[]): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + if (!backendRefs || backendRefs.length === 0) { + return { isValid: true, errors, warnings }; + } + + backendRefs.forEach((ref, index) => { + // service name - required field + if (!ref.serviceName || ref.serviceName.trim() === '') { + errors.push({ + field: `backendRefs[${index}].serviceName`, + message: `Backend Reference ${index + 1}: Service name is required`, + severity: 'error', + }); + } + + // service namespace - required field + if (!ref.serviceNamespace || ref.serviceNamespace.trim() === '') { + errors.push({ + field: `backendRefs[${index}].serviceNamespace`, + message: `Backend Reference ${index + 1}: Service namespace is required`, + severity: 'error', + }); + } + + // port validation - must be valid integer in range + const portError = validateNumberField( + ref.port, + `Backend Reference ${index + 1}: Port`, + 1, + 65535, + true, + ); + if (portError) { + errors.push({ + field: `backendRefs[${index}].port`, + message: portError.message, + severity: portError.severity, + }); + } + + // weight validation - must be valid integer in range + const weightError = validateNumberField( + ref.weight, + `Backend Reference ${index + 1}: Weight`, + 1, + 1000000, + true, + ); + if (weightError) { + errors.push({ + field: `backendRefs[${index}].weight`, + message: weightError.message, + severity: weightError.severity, + }); + } + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +}; + +// filters validation - detailed validation for each filter type +export const validateFilters = (filters: any[]): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + if (!filters || filters.length === 0) { + return { isValid: true, errors, warnings }; + } + + filters.forEach((filter, filterIndex) => { + // type - required field + if (!filter.type || filter.type.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].type`, + message: `Filter ${filterIndex + 1}: type is required`, + severity: 'error', + }); + return; // skip further validation if no type + } + + // type-specific validation + switch (filter.type) { + case 'RequestHeaderModifier': + case 'ResponseHeaderModifier': { + const modifierKey = + filter.type === 'RequestHeaderModifier' + ? 'requestHeaderModifier' + : 'responseHeaderModifier'; + const modifier = filter[modifierKey] || {}; + + // check if at least one operation is configured + const hasAdd = modifier.add && modifier.add.length > 0; + const hasSet = modifier.set && modifier.set.length > 0; + const hasRemove = modifier.remove && modifier.remove.length > 0; + + if (!hasAdd && !hasSet && !hasRemove) { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}`, + message: `Filter ${filterIndex + 1}: ${ + filter.type + } must have at least one operation (add, set, or remove)`, + severity: 'error', + }); + } + + // validate add operations + if (hasAdd) { + modifier.add.forEach((header: any, headerIndex: number) => { + if (!header.name || header.name.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.add[${headerIndex}].name`, + message: `Filter ${filterIndex + 1}, Add Header ${ + headerIndex + 1 + }: name is required`, + severity: 'error', + }); + } else { + // validate header name format + const validHeaderName = /^[a-zA-Z0-9\-_]+$/.test(header.name); + if (!validHeaderName) { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.add[${headerIndex}].name`, + message: `Filter ${filterIndex + 1}, Add Header ${ + headerIndex + 1 + }: invalid header name format`, + severity: 'error', + }); + } + } + + if (!header.value || header.value.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.add[${headerIndex}].value`, + message: `Filter ${filterIndex + 1}, Add Header ${ + headerIndex + 1 + }: value is required`, + severity: 'error', + }); + } + }); + } + + // validate set operations + if (hasSet) { + modifier.set.forEach((header: any, headerIndex: number) => { + if (!header.name || header.name.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.set[${headerIndex}].name`, + message: `Filter ${filterIndex + 1}, Set Header ${ + headerIndex + 1 + }: name is required`, + severity: 'error', + }); + } else { + // validate header name format + const validHeaderName = /^[a-zA-Z0-9\-_]+$/.test(header.name); + if (!validHeaderName) { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.set[${headerIndex}].name`, + message: `Filter ${filterIndex + 1}, Set Header ${ + headerIndex + 1 + }: invalid header name format`, + severity: 'error', + }); + } + } + + if (!header.value || header.value.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.set[${headerIndex}].value`, + message: `Filter ${filterIndex + 1}, Set Header ${ + headerIndex + 1 + }: value is required`, + severity: 'error', + }); + } + }); + } + + // validate remove operations + if (hasRemove) { + modifier.remove.forEach((headerName: any, headerIndex: number) => { + const name = typeof headerName === 'string' ? headerName : headerName?.name; + if (!name || name.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.remove[${headerIndex}]`, + message: `Filter ${filterIndex + 1}, Remove Header ${ + headerIndex + 1 + }: name is required`, + severity: 'error', + }); + } else { + // validate header name format + const validHeaderName = /^[a-zA-Z0-9\-_]+$/.test(name); + if (!validHeaderName) { + errors.push({ + field: `filters[${filterIndex}].${modifierKey}.remove[${headerIndex}]`, + message: `Filter ${filterIndex + 1}, Remove Header ${ + headerIndex + 1 + }: invalid header name format`, + severity: 'error', + }); + } + } + }); + } + + // check for duplicate header names within operations + const checkDuplicates = (headers: any[], operation: string) => { + const names = headers + .map((h) => (typeof h === 'string' ? h : h.name)?.toLowerCase()) + .filter(Boolean); + const duplicates = names.filter((name, index) => names.indexOf(name) !== index); + if (duplicates.length > 0) { + warnings.push({ + field: `filters[${filterIndex}].${modifierKey}.${operation}`, + message: `Filter ${filterIndex + 1}, ${operation}: duplicate header names: ${[ + ...new Set(duplicates), + ].join(', ')}`, + severity: 'warning', + }); + } + }; + + if (hasAdd) checkDuplicates(modifier.add, 'add'); + if (hasSet) checkDuplicates(modifier.set, 'set'); + if (hasRemove) checkDuplicates(modifier.remove, 'remove'); + break; + } + + case 'RequestRedirect': { + const redirect = filter.requestRedirect || {}; + + // at least one redirect parameter must be specified + const hasScheme = redirect.scheme && redirect.scheme.trim() !== ''; + const hasHostname = redirect.hostname && redirect.hostname.trim() !== ''; + const hasPort = redirect.port !== undefined && redirect.port !== null; + const hasStatusCode = redirect.statusCode !== undefined && redirect.statusCode !== null; + const hasPath = redirect.path && redirect.path.type; + + if (!hasScheme && !hasHostname && !hasPort && !hasStatusCode && !hasPath) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect`, + message: `Filter ${ + filterIndex + 1 + }: RequestRedirect must specify at least one parameter (scheme, hostname, port, statusCode, or path)`, + severity: 'error', + }); + } + if (!hasStatusCode) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.statusCode`, + message: `Filter ${filterIndex + 1}: statusCode is required for RequestRedirect`, + severity: 'error', + }); + } + if (!hasPort) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.port`, + message: `Filter ${filterIndex + 1}: port is required for RequestRedirect`, + severity: 'error', + }); + } + // validate scheme + if (hasScheme && !['http', 'https'].includes(redirect.scheme.toLowerCase())) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.scheme`, + message: `Filter ${filterIndex + 1}: scheme must be 'http' or 'https'`, + severity: 'error', + }); + } + + // validate hostname format + if (hasHostname) { + const validHostname = + /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( + redirect.hostname, + ); + if (!validHostname) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.hostname`, + message: `Filter ${filterIndex + 1}: invalid hostname format`, + severity: 'error', + }); + } + } + + // validate port - enhanced with number validation + if (hasPort) { + const portError = validateNumberField( + redirect.port, + `Filter ${filterIndex + 1}: Port`, + 1, + 65535, + false, + ); + if (portError) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.port`, + message: portError.message, + severity: portError.severity, + }); + } + } + + // validate status code - enhanced with number validation + if (hasStatusCode) { + const statusError = validateNumberField( + redirect.statusCode, + `Filter ${filterIndex + 1}: Status Code`, + 301, + 308, + false, + ); + if (statusError) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.statusCode`, + message: statusError.message, + severity: statusError.severity, + }); + } else if (![301, 302, 303, 307, 308].includes(redirect.statusCode)) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.statusCode`, + message: `Filter ${ + filterIndex + 1 + }: statusCode must be one of: 301, 302, 303, 307, 308`, + severity: 'error', + }); + } + } + + // validate path + if (hasPath) { + if (!['ReplaceFullPath', 'ReplacePrefixMatch'].includes(redirect.path.type)) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.path.type`, + message: `Filter ${ + filterIndex + 1 + }: path type must be 'ReplaceFullPath' or 'ReplacePrefixMatch'`, + severity: 'error', + }); + } + + if (redirect.path.type === 'ReplaceFullPath') { + if (!redirect.path.replaceFullPath || redirect.path.replaceFullPath.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.path.replaceFullPath`, + message: `Filter ${ + filterIndex + 1 + }: replaceFullPath is required when type is 'ReplaceFullPath'`, + severity: 'error', + }); + } else if (!redirect.path.replaceFullPath.startsWith('/')) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.path.replaceFullPath`, + message: `Filter ${filterIndex + 1}: replaceFullPath must start with '/'`, + severity: 'error', + }); + } + } + + if (redirect.path.type === 'ReplacePrefixMatch') { + if ( + !redirect.path.replacePrefixMatch || + redirect.path.replacePrefixMatch.trim() === '' + ) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.path.replacePrefixMatch`, + message: `Filter ${ + filterIndex + 1 + }: replacePrefixMatch is required when type is 'ReplacePrefixMatch'`, + severity: 'error', + }); + } else if (!redirect.path.replacePrefixMatch.startsWith('/')) { + errors.push({ + field: `filters[${filterIndex}].requestRedirect.path.replacePrefixMatch`, + message: `Filter ${filterIndex + 1}: replacePrefixMatch must start with '/'`, + severity: 'error', + }); + } + } + } + break; + } + + case 'URLRewrite': { + const rewrite = filter.urlRewrite || {}; + + // at least one rewrite parameter must be specified + const hasHostname = rewrite.hostname && rewrite.hostname.trim() !== ''; + const hasPath = rewrite.path && rewrite.path.type; + + if (!hasHostname && !hasPath) { + errors.push({ + field: `filters[${filterIndex}].urlRewrite`, + message: `Filter ${ + filterIndex + 1 + }: URLRewrite must specify at least one parameter (hostname or path)`, + severity: 'error', + }); + } + + // validate hostname format + if (hasHostname) { + const validHostname = + /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( + rewrite.hostname, + ); + if (!validHostname) { + errors.push({ + field: `filters[${filterIndex}].urlRewrite.hostname`, + message: `Filter ${filterIndex + 1}: invalid hostname format`, + severity: 'error', + }); + } + } + + // validate path (same logic as RequestRedirect) + if (hasPath) { + if (!['ReplaceFullPath', 'ReplacePrefixMatch'].includes(rewrite.path.type)) { + errors.push({ + field: `filters[${filterIndex}].urlRewrite.path.type`, + message: `Filter ${ + filterIndex + 1 + }: path type must be 'ReplaceFullPath' or 'ReplacePrefixMatch'`, + severity: 'error', + }); + } + + if (rewrite.path.type === 'ReplaceFullPath') { + if (!rewrite.path.replaceFullPath || rewrite.path.replaceFullPath.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].urlRewrite.path.replaceFullPath`, + message: `Filter ${ + filterIndex + 1 + }: replaceFullPath is required when type is 'ReplaceFullPath'`, + severity: 'error', + }); + } else if (!rewrite.path.replaceFullPath.startsWith('/')) { + errors.push({ + field: `filters[${filterIndex}].urlRewrite.path.replaceFullPath`, + message: `Filter ${filterIndex + 1}: replaceFullPath must start with '/'`, + severity: 'error', + }); + } + } + + if (rewrite.path.type === 'ReplacePrefixMatch') { + if (!rewrite.path.replacePrefixMatch || rewrite.path.replacePrefixMatch.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].urlRewrite.path.replacePrefixMatch`, + message: `Filter ${ + filterIndex + 1 + }: replacePrefixMatch is required when type is 'ReplacePrefixMatch'`, + severity: 'error', + }); + } else if (!rewrite.path.replacePrefixMatch.startsWith('/')) { + errors.push({ + field: `filters[${filterIndex}].urlRewrite.path.replacePrefixMatch`, + message: `Filter ${filterIndex + 1}: replacePrefixMatch must start with '/'`, + severity: 'error', + }); + } + } + } + break; + } + + case 'RequestMirror': { + const mirror = filter.requestMirror || {}; + const backendRef = mirror.backendRef || {}; + + // service name is required + if (!backendRef.name || backendRef.name.trim() === '') { + errors.push({ + field: `filters[${filterIndex}].requestMirror.backendRef.name`, + message: `Filter ${filterIndex + 1}: RequestMirror service name is required`, + severity: 'error', + }); + } + + // validate port if specified - enhanced with number validation + if (backendRef.port !== undefined && backendRef.port !== null) { + const portError = validateNumberField( + backendRef.port, + `Filter ${filterIndex + 1}: RequestMirror Port`, + 1, + 65535, + false, + ); + if (portError) { + errors.push({ + field: `filters[${filterIndex}].requestMirror.backendRef.port`, + message: portError.message, + severity: portError.severity, + }); + } + } + break; + } + + default: + warnings.push({ + field: `filters[${filterIndex}].type`, + message: `Filter ${filterIndex + 1}: unknown filter type '${filter.type}'`, + severity: 'warning', + }); + break; + } + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +}; + +// main validation - UPDATE to use detailed filter validation +export const validateCompleteRule = (currentRule: any): ValidationResult => { + const allErrors: ValidationError[] = []; + const allWarnings: ValidationError[] = []; + + // matches + if (currentRule.matches && currentRule.matches.length > 0) { + const matchesValidation = validateMatches(currentRule.matches); + allErrors.push(...matchesValidation.errors); + allWarnings.push(...matchesValidation.warnings); + } + + // detailed filters validation + if (currentRule.filters && currentRule.filters.length > 0) { + const filtersValidation = validateFilters(currentRule.filters); + allErrors.push(...filtersValidation.errors); + allWarnings.push(...filtersValidation.warnings); + } + + // backend references validation + if (currentRule.backendRefs && currentRule.backendRefs.length > 0) { + const backendRefsValidation = validateBackendRefs(currentRule.backendRefs); + allErrors.push(...backendRefsValidation.errors); + allWarnings.push(...backendRefsValidation.warnings); + } + + // at least 1 section + const hasMatches = currentRule.matches?.length > 0; + const hasFilters = currentRule.filters?.length > 0; + const hasBackendRefs = currentRule.backendRefs?.length > 0; + + if (!hasMatches && !hasFilters && !hasBackendRefs) { + allErrors.push({ + field: 'rule', + message: 'rule must have at least one match, filter, or backend reference configured', + severity: 'error', + }); + } + + return { + isValid: allErrors.length === 0, + errors: allErrors, + warnings: allWarnings, + }; +}; diff --git a/src/components/httproute/useHTTPRouteActions.tsx b/src/components/httproute/useHTTPRouteActions.tsx new file mode 100644 index 0000000..e874d64 --- /dev/null +++ b/src/components/httproute/useHTTPRouteActions.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; +import { ExtensionHookResult } from '@openshift-console/dynamic-plugin-sdk/lib/api/common-types'; +import { Action } from '@openshift-console/dynamic-plugin-sdk/lib/extensions/actions'; +import { + K8sResourceCommon, + useK8sModel, + getGroupVersionKindForResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { AccessReviewResourceAttributes } from '@openshift-console/dynamic-plugin-sdk/lib/extensions/console-types'; +import { + useAnnotationsModal, + useDeleteModal, + useLabelsModal, +} from '@openshift-console/dynamic-plugin-sdk'; + +const useHTTPRouteActions = (obj: K8sResourceCommon): ExtensionHookResult => { + const history = useHistory(); + const gvk = obj ? getGroupVersionKindForResource(obj) : undefined; + const [httpRouteModel] = useK8sModel( + gvk + ? { group: gvk.group, version: gvk.version, kind: gvk.kind } + : { group: '', version: '', kind: '' }, + ); + const launchDeleteModal = useDeleteModal(obj); + const launchLabelsModal = useLabelsModal(obj); + const launchAnnotationsModal = useAnnotationsModal(obj); + + const actions = React.useMemo(() => { + if (!obj || obj.kind !== 'HTTPRoute') return []; + const api = (obj.apiVersion || '').replace('/', '~'); + const namespace = obj.metadata?.namespace || 'default'; + const name = obj.metadata?.name || ''; + + const updateAccess: AccessReviewResourceAttributes | undefined = httpRouteModel + ? { + group: httpRouteModel.apiGroup, + resource: httpRouteModel.plural, + verb: 'update', + name, + namespace, + } + : undefined; + const deleteAccess: AccessReviewResourceAttributes | undefined = httpRouteModel + ? { + group: httpRouteModel.apiGroup, + resource: httpRouteModel.plural, + verb: 'delete', + name, + namespace, + } + : undefined; + + const actionsList: Action[] = [ + { + id: 'edit-labels-httproute', + label: 'Edit labels', + cta: launchLabelsModal, + accessReview: updateAccess, + }, + { + id: 'edit-annotations-httproute', + label: 'Edit annotations', + cta: launchAnnotationsModal, + accessReview: updateAccess, + }, + { + id: 'kuadrant-http-route-edit-form', + label: 'Edit', + description: 'Edit via form', + cta: () => + history.push({ + pathname: `/k8s/ns/${namespace}/${api}~HTTPRoute/${name}/edit`, + }), + insertBefore: 'edit-yaml', + accessReview: updateAccess, + }, + { + id: 'delete-httproute', + label: 'Delete', + cta: launchDeleteModal, + accessReview: deleteAccess, + }, + ]; + + return actionsList; + }, [history, obj, httpRouteModel, launchAnnotationsModal, launchDeleteModal, launchLabelsModal]); + + return [actions, true, undefined]; +}; + +export default useHTTPRouteActions; diff --git a/src/utils/ParentReferencesSelect.tsx b/src/utils/ParentReferencesSelect.tsx new file mode 100644 index 0000000..7267843 --- /dev/null +++ b/src/utils/ParentReferencesSelect.tsx @@ -0,0 +1,499 @@ +import * as React from 'react'; +import { + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + FormSelect, + FormSelectOption, + TextInput, + Button, + ButtonVariant, + Alert, + AlertVariant, + FormFieldGroupExpandable, + FormFieldGroupHeader, + Popover, +} from '@patternfly/react-core'; +import { HelpIcon, PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; +import { useTranslation } from 'react-i18next'; +import { useK8sWatchResource, useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; + +export interface ParentReference { + id: string; + gatewayName: string; + gatewayNamespace: string; + sectionName: string; + port: number; +} +export const validateParentReference = (ref: ParentReference): boolean => { + // Gateway name is required + if (!ref.gatewayName || ref.gatewayName.trim() === '') { + return false; + } + + // Section name is required + if (!ref.sectionName || ref.sectionName.trim() === '') { + return false; + } + + // Port is required and must be valid + if (!ref.port || ref.port <= 0 || ref.port > 65535 || !Number.isInteger(ref.port)) { + return false; + } + + // Gateway namespace should be valid (optional but if present should not be empty) + if (ref.gatewayNamespace && ref.gatewayNamespace.trim() === '') { + return false; + } + + return true; +}; + +// Validate all parent references +export const validateAllParentReferences = (parentRefs: ParentReference[]): boolean => { + if (parentRefs.length === 0) { + return false; // At least one parent reference is required + } + + return parentRefs.every(validateParentReference); +}; +// Extend Gateway interface for validation +interface Gateway { + metadata: { + name: string; + namespace: string; + deletionTimestamp?: string; + }; + spec: { + listeners?: Array<{ + name: string; + port: number; + protocol: string; + allowedRoutes?: { + namespaces?: { + from?: 'All' | 'Same' | 'Selector'; + }; + kinds?: Array<{ + group?: string; + kind: string; + }>; + }; + }>; + }; + status?: { + conditions?: Array<{ + type: string; + status: string; + }>; + listeners?: Array<{ + name: string; + conditions?: Array<{ + type: string; + status: string; + }>; + }>; + }; +} + +interface ParentReferencesSelectProps { + parentRefs: ParentReference[]; + onChange: (parentRefs: ParentReference[]) => void; + isDisabled?: boolean; +} + +const ParentReferencesSelect: React.FC = ({ + parentRefs, + onChange, + isDisabled = false, +}) => { + const { t } = useTranslation('plugin__gateway-api-console-plugin'); + const [availableGateways, setAvailableGateways] = React.useState([]); + const [selectedNamespace] = useActiveNamespace(); + + // Load all available Gateways + const gatewayResource = { + groupVersionKind: { + group: 'gateway.networking.k8s.io', + version: 'v1', + kind: 'Gateway', + }, + isList: true, + }; + + const [gatewayData, gatewayLoaded, gatewayError] = + useK8sWatchResource(gatewayResource); + + React.useEffect(() => { + if (gatewayLoaded && !gatewayError && Array.isArray(gatewayData)) { + setAvailableGateways(gatewayData); + } + }, [gatewayData, gatewayLoaded, gatewayError]); + + // Gateway validation function + const validateGateway = (gateway: Gateway): string | null => { + // (1) Gateway exists → Gateway is deleting / Terminating + if (gateway.metadata.deletionTimestamp) { + return t('Gateway is terminating.'); + } + + // (2) Gateway exists → Route type is not supported + const supportsHTTPRoute = gateway.spec.listeners?.some((listener) => { + // Check allowedRoutes at the listener level + const allowedKinds = listener.allowedRoutes?.kinds; + if (allowedKinds && allowedKinds.length > 0) { + return allowedKinds.some( + (kind) => + kind.kind === 'HTTPRoute' && + (kind.group === 'gateway.networking.k8s.io' || !kind.group), + ); + } + // If allowedKinds are not specified, all types are supported by default for HTTP/HTTPS + return listener.protocol === 'HTTP' || listener.protocol === 'HTTPS'; + }); + + if (!supportsHTTPRoute) { + return t('Only HTTPRoute is supported by this Gateway.'); + } + + // (3) Gateway exists → gateway allowedRoutes does not allow + const allowsFromNamespace = gateway.spec.listeners?.some((listener) => { + const namespacePolicy = listener.allowedRoutes?.namespaces?.from || 'Same'; + return ( + namespacePolicy === 'All' || + (namespacePolicy === 'Same' && gateway.metadata.namespace === selectedNamespace) + ); + }); + + if (!allowsFromNamespace) { + return t('Not allowed by Gateway settings.'); + } + + return null; // Gateway is available + }; + + // Listener validation function + const validateListener = (gateway: Gateway, listenerName: string): string | null => { + // First, validate the Gateway itself + const gatewayValidation = validateGateway(gateway); + if (gatewayValidation) return gatewayValidation; + + // (4) Listener is unavailable (Ready=False) + const listenerStatus = gateway.status?.listeners?.find((ls) => ls.name === listenerName); + if (listenerStatus) { + const readyCondition = listenerStatus.conditions?.find((c) => c.type === 'Ready'); + if (readyCondition && readyCondition.status !== 'True') { + return t('Listener is not available for route binding.'); + } + } + + return null; // Listener is available + }; + + // Sort Gateways: available first, then unavailable + const getSortedGateways = () => { + return [...availableGateways].sort((a, b) => { + const restrictionA = validateGateway(a); + const restrictionB = validateGateway(b); + + // Available (without restriction) first + if (!restrictionA && restrictionB) return -1; + if (restrictionA && !restrictionB) return 1; + + // Within each group, sort by name + return a.metadata.name.localeCompare(b.metadata.name); + }); + }; + + // Sort Listeners + const getSortedSections = (gatewayName: string, gatewayNamespace: string) => { + const gateway = availableGateways.find( + (gw) => gw.metadata.name === gatewayName && gw.metadata.namespace === gatewayNamespace, + ); + + if (!gateway) return []; + + return [...(gateway.spec.listeners || [])].sort((a, b) => { + const restrictionA = validateListener(gateway, a.name); + const restrictionB = validateListener(gateway, b.name); + if (!restrictionA && restrictionB) return -1; + if (restrictionA && !restrictionB) return 1; + return a.name.localeCompare(b.name); + }); + }; + + // Add new parent reference + const addParentReference = () => { + const newParentRef: ParentReference = { + id: `parent-ref-${Date.now()}`, + gatewayName: '', + gatewayNamespace: '', + sectionName: '', + port: 0, + }; + onChange([...parentRefs, newParentRef]); + }; + + // Remove parent reference + const removeParentReference = (id: string) => { + const updatedRefs = parentRefs.filter((ref) => ref.id !== id); + onChange(updatedRefs); + }; + + // Update parent reference + const updateParentReference = ( + id: string, + field: keyof ParentReference, + value: string | number, + ) => { + const updatedRefs = parentRefs.map((ref) => { + if (ref.id === id) { + const updatedRef = { ...ref, [field]: value }; + + // If Gateway is changed, automatically update namespace and reset section + if (field === 'gatewayName') { + const selectedGateway = availableGateways.find((gw) => gw.metadata.name === value); + if (selectedGateway) { + updatedRef.gatewayNamespace = selectedGateway.metadata.namespace; + updatedRef.sectionName = ''; + updatedRef.port = 0; + } + } + + // If Section is changed, update port + if (field === 'sectionName') { + const selectedGateway = availableGateways.find( + (gw) => + gw.metadata.name === ref.gatewayName && + gw.metadata.namespace === ref.gatewayNamespace, + ); + if (selectedGateway) { + const listener = selectedGateway.spec.listeners?.find((l) => l.name === value); + if (listener) { + updatedRef.port = listener.port; + } + } + } + + return updatedRef; + } + return ref; + }); + onChange(updatedRefs); + }; + + // Validation check + const hasValidParentRef = validateAllParentReferences(parentRefs); + + return ( + + {t('Parent references')} *{' '} + + {t( + 'Attaches this route to the selected Gateway(s), linking it to the entry point.', + )} +

+ } + aria-label={t('Parent references help')} + > + +
+ + } + fieldId="parent-references" + > + {!hasValidParentRef && ( + + )} + + {parentRefs.map((parentRef, index) => { + const descriptionParts: string[] = []; + if (parentRef.gatewayNamespace) + descriptionParts.push(`${t('Namespace')}: ${parentRef.gatewayNamespace}`); + if (parentRef.sectionName) + descriptionParts.push(`${t('Section')}: ${parentRef.sectionName}`); + if (parentRef.port && parentRef.port > 0) + descriptionParts.push(`${t('Port')}: ${parentRef.port}`); + const description = descriptionParts.length > 0 ? descriptionParts.join(' | ') : undefined; + + return ( + removeParentReference(parentRef.id)} + aria-label={t('Remove parent reference')} + icon={} + /> + ) + } + /> + } + style={{ + marginBottom: '16px', + }} + > +
+ {/* Gateway selection */} +
+ + + updateParentReference(parentRef.id, 'gatewayName', value) + } + aria-label={t('Select Gateway')} + isDisabled={isDisabled} + > + + {getSortedGateways().map((gateway) => { + const restriction = validateGateway(gateway); + + return ( + + ); + })} + + + + {t('The name of the Gateway to attach to.')} + + + + + + + + + {t('The namespace of the Gateway.')} + + + +
+ + {/* Section and Port */} +
+ + + updateParentReference(parentRef.id, 'sectionName', value) + } + aria-label={t('Select Section')} + isDisabled={isDisabled || !parentRef.gatewayName} + > + + {getSortedSections(parentRef.gatewayName, parentRef.gatewayNamespace).map( + (listener) => { + const gateway = availableGateways.find( + (gw) => + gw.metadata.name === parentRef.gatewayName && + gw.metadata.namespace === parentRef.gatewayNamespace, + ); + const restriction = gateway + ? validateListener(gateway, listener.name) + : null; + + return ( + + ); + }, + )} + + + + + + +
+
+
+ ); + })} + + {/* Add button */} + {!isDisabled && ( + + )} +
+ ); +}; + +export default ParentReferencesSelect; diff --git a/src/utils/gatewayCreateEditHelpers.ts b/src/utils/gatewayCreateEditHelpers.ts index 2b58a76..8ac34b0 100644 --- a/src/utils/gatewayCreateEditHelpers.ts +++ b/src/utils/gatewayCreateEditHelpers.ts @@ -6,7 +6,12 @@ export const generateUniqueId = (prefix = 'item'): string => { }; // Remove certificates and TLS options when TLS mode is Passthrough -export const removeCertsAndTlsOptionsForPassthrough = (listener: any) => { +export const removeCertsAndTlsOptionsForPassthrough = (listener: { + protocol: 'HTTP' | 'HTTPS' | 'TLS' | 'TCP' | 'UDP'; + tlsMode: 'Terminate' | 'Passthrough'; + tlsOptions?: Array; + certificateRefs?: Array; +}) => { if (listener.protocol === 'HTTPS' || listener.protocol === 'TLS') { if (listener.tlsMode === 'Passthrough') { return {