diff --git a/README.md b/README.md index 6f80c80..9f95c66 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,10 @@ OpenShift plugin for managing Kubernetes Gateway API resources - - ## Screenshots ![Overview](docs/images/overview.gif) + ## Running - Target a running OCP with `oc login` @@ -110,13 +109,13 @@ with this namespace as follows: ```tsx conster Header: React.FC = () => { - const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const { t } = useTranslation('plugin__gateway-api-console-plugin'); return

{t('Hello, World!')}

; }; ``` For labels in `console-extensions.json`, you can use the format -`%plugin__kuadrant-console-plugin~My Label%`. Console will replace the value with +`%plugin__gateway-api-console-plugin~My Label%`. Console will replace the value with the message for the current language from the `plugin__kuadrant-console` namespace. For example: @@ -126,7 +125,7 @@ namespace. For example: "properties": { "id": "admin-demo-section", "perspective": "admin", - "name": "%plugin__kuadrant-console-plugin~Plugin Template%" + "name": "%plugin__gateway-api-console-plugin~Plugin Template%" } } ``` @@ -165,18 +164,17 @@ Update `settings.json` (File > Preferences > Settings): ```json "editor.formatOnSave": true ``` + ## Version matrix | kuadrant-console-plugin version | PatternFly version | Openshift console version | Dynamic Plugin SDK | -|---------------------------------|--------------------|---------------------------|--------------------| -| v0.0.3 - v0.0.18 | 5 | v4.17.x | v1.6.0 | -| TBD | 5 | v4.18.x | v1.8.0 | -| TBD | 6 | v4.19.x | TBD | +| ------------------------------- | ------------------ | ------------------------- | ------------------ | +| v0.0.3 - v0.0.18 | 5 | v4.17.x | v1.6.0 | +| TBD | 5 | v4.18.x | v1.8.0 | +| TBD | 6 | v4.19.x | TBD | Openshift console is configured to share modules with its dynamic plugins (console plugins). For more information on versions and changes to the shared modules, please see the shared modules [documentation](https://www.npmjs.com/package/@openshift-console/dynamic-plugin-sdk?activeTab=readme) - - ## References - [Console Plugin SDK README](https://github.com/openshift/console/tree/master/frontend/packages/console-dynamic-plugin-sdk) diff --git a/console-extensions.json b/console-extensions.json index fe51488..649fbe6 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -1 +1,17 @@ -[] +[ + { + "type": "console.tab/horizontalNav", + "properties": { + "model": { + "group": "gateway.networking.k8s.io", + "version": "v1", + "kind": "Gateway" + }, + "page": { + "name": "Attached", + "href": "attached" + }, + "component": { "$codeRef": "GatewaySingleOverview" } + } + } +] diff --git a/downstream.js b/downstream.js index c6efe7f..10c240e 100755 --- a/downstream.js +++ b/downstream.js @@ -20,15 +20,11 @@ const replacements = { mappings: { // Direct string replacements for links.ts // Order matters: specific URLs first to prevent partial matches - 'https://docs.kuadrant.io/latest/kuadrant-operator/doc/user-guides/secure-protect-connect-single-multi-cluster/': - `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/html-single/configuring_and_deploying_gateway_policies_with_connectivity_link/index`, - 'https://docs.kuadrant.io/latest/kuadrant-operator/doc/observability/examples/': - `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/html-single/connectivity_link_observability_guide/index`, - 'https://docs.kuadrant.io': - `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/`, - 'https://github.com/Kuadrant/kuadrant-operator/releases': - `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/html-single/release_notes_for_connectivity_link_${version}/index`, - 'Kuadrant': 'Connectivity Link', + 'https://docs.kuadrant.io/latest/kuadrant-operator/doc/user-guides/secure-protect-connect-single-multi-cluster/': `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/html-single/configuring_and_deploying_gateway_policies_with_connectivity_link/index`, + 'https://docs.kuadrant.io/latest/kuadrant-operator/doc/observability/examples/': `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/html-single/connectivity_link_observability_guide/index`, + 'https://docs.kuadrant.io': `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/`, + 'https://github.com/Kuadrant/kuadrant-operator/releases': `https://docs.redhat.com/en/documentation/red_hat_connectivity_link/${version}/html-single/release_notes_for_connectivity_link_${version}/index`, + Kuadrant: 'Connectivity Link', }, }, @@ -37,7 +33,7 @@ const replacements = { type: 'regex', patterns: [ { - search: /%plugin__kuadrant-console-plugin~Kuadrant%/g, + search: /%plugin__gateway-api-console-plugin~Kuadrant%/g, replace: 'Connectivity Link', }, ], @@ -50,7 +46,6 @@ const replacements = { }, }; - function replaceSimpleStrings(filePath, mappings) { try { let content = fs.readFileSync(filePath, 'utf-8'); diff --git a/i18n-scripts/i18next-parser.config.js b/i18n-scripts/i18next-parser.config.js index 17b651f..5fddd9c 100644 --- a/i18n-scripts/i18next-parser.config.js +++ b/i18n-scripts/i18next-parser.config.js @@ -9,7 +9,7 @@ module.exports = { locales: ['en'], namespaceSeparator: '~', reactNamespace: false, - defaultNamespace: 'plugin__kuadrant-console-plugin', + defaultNamespace: 'plugin__gateway-api-console-plugin', useKeysAsDefaultValue: true, // see below for more details diff --git a/i18next-parser.config.js b/i18next-parser.config.js index 76bfb47..a192765 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -9,7 +9,7 @@ module.exports = { locales: ['en'], namespaceSeparator: '~', reactNamespace: false, - defaultNamespace: 'plugin__kuadrant-console-plugin', + defaultNamespace: 'plugin__gateway-api-console-plugin', useKeysAsDefaultValue: true, // see below for more details diff --git a/locales/en/gateway-api-console-plugin.json b/locales/en/gateway-api-console-plugin.json deleted file mode 100644 index 9e26dfe..0000000 --- a/locales/en/gateway-api-console-plugin.json +++ /dev/null @@ -1 +0,0 @@ -{} \ 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 new file mode 100644 index 0000000..18e826c --- /dev/null +++ b/locales/en/plugin__gateway-api-console-plugin.json @@ -0,0 +1,19 @@ +{ + "Attached Resources": "Attached Resources", + "Gateway Console Plugin": "Gateway Console Plugin", + "Name": "Name", + "Namespace": "Namespace", + "No attached routes found": "No attached routes found", + "No route resources matched": "No route resources matched", + "Some references for the HTTPRoute could not be resolved.": "Some references for the HTTPRoute could not be resolved.", + "Status": "Status", + "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.", + "The Gateway is accepted and programmed in the data plane.": "The Gateway is accepted and programmed in the data plane.", + "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 resource is being processed.": "The resource is being processed.", + "The status of the resource is unknown.": "The status of the resource is unknown.", + "Type": "Type" +} \ No newline at end of file diff --git a/package.json b/package.json index 4c55125..8ad27f7 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "loadType": "Preload" }, "exposedModules": { + "GatewaySingleOverview": "./components/GatewaySingleOverview" }, "dependencies": { "@console/pluginAPI": "*" diff --git a/src/components/AttachedResources.tsx b/src/components/AttachedResources.tsx new file mode 100644 index 0000000..ba68ac5 --- /dev/null +++ b/src/components/AttachedResources.tsx @@ -0,0 +1,228 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, AlertGroup, EmptyState, EmptyStateBody, Title } from '@patternfly/react-core'; +import { + K8sResourceKind, + ResourceLink, + useK8sWatchResources, + VirtualizedTable, + TableData, + RowProps, + TableColumn, + WatchK8sResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { SearchIcon } from '@patternfly/react-icons'; +import { getStatusLabel } from '../utils/statusLabel'; + +type AttachedResourcesProps = { + resource: K8sResourceKind; +}; + +const AttachedResources: React.FC = ({ resource }) => { + const { t } = useTranslation('plugin__gateway-api-console-plugin'); + + const associatedResources: { [key: string]: WatchK8sResource } = { + HTTPRoute: { + groupVersionKind: { group: 'gateway.networking.k8s.io', version: 'v1', kind: 'HTTPRoute' }, + isList: true, + // Search cluster-wide for HTTPRoutes that might reference this gateway + }, + }; + + const watchedResources = useK8sWatchResources<{ [key: string]: K8sResourceKind[] }>( + associatedResources, + ); + + const resourceGroup = resource.apiVersion.includes('/') ? resource.apiVersion.split('/')[0] : ''; + + const attachedRoutes = React.useMemo(() => { + let routesArray: K8sResourceKind[] = []; + + // Process HTTPRoutes + const httpRoutes = watchedResources.HTTPRoute; + console.log('HTTPROUTES', httpRoutes); + + if (httpRoutes?.loaded && !httpRoutes.loadError && httpRoutes.data) { + console.log('ALL HTTP ROUTES:', httpRoutes.data); + const matchingRoutes = (httpRoutes.data as K8sResourceKind[]).filter((route) => { + console.log('CHECKING ROUTE:', route.metadata.name); + console.log('ROUTE STATUS:', route.status); + + const statusParents = route.status?.parents ?? []; + console.log('STATUS PARENTS FOR ROUTE', route.metadata.name, ':', statusParents); + + // Also check spec.parentRefs as fallback for debugging + const specParents = route.spec?.parentRefs ?? []; + console.log('SPEC PARENTS FOR ROUTE', route.metadata.name, ':', specParents); + + console.log('LOOKING FOR GATEWAY:', { + name: resource.metadata.name, + namespace: resource.metadata.namespace, + kind: resource.kind, + group: resourceGroup, + }); + + // Check both status.parents and spec.parentRefs for matches + const checkParentRef = (parentRef: any) => { + if (!parentRef) return false; + + const refNamespace = parentRef.namespace ?? resource.metadata.namespace; + const refGroup = parentRef.group ?? 'gateway.networking.k8s.io'; + const refKind = parentRef.kind ?? 'Gateway'; + + const matches = + parentRef.name === resource.metadata.name && + refNamespace === resource.metadata.namespace && + refGroup === resourceGroup && + refKind === resource.kind; + + console.log('MATCH CHECK:', { + parentRef, + expected: { + name: resource.metadata.name, + namespace: resource.metadata.namespace, + group: resourceGroup, + kind: resource.kind, + }, + actual: { + name: parentRef.name, + namespace: refNamespace, + group: refGroup, + kind: refKind, + }, + matches, + }); + + return matches; + }; + + // First check status.parents + const statusMatch = statusParents.some((parent: any) => { + console.log('CHECKING STATUS PARENT REF:', parent.parentRef); + return checkParentRef(parent.parentRef); + }); + + // Also check spec.parentRefs as fallback + const specMatch = specParents.some((parentRef: any) => { + console.log('CHECKING SPEC PARENT REF:', parentRef); + return checkParentRef(parentRef); + }); + + return statusMatch || specMatch; + }); + console.log('MATCHING ROUTES:', matchingRoutes); + + routesArray = routesArray.concat(matchingRoutes); + } + + return routesArray; + }, [watchedResources, resource, resourceGroup]); + + const columns: TableColumn[] = [ + { + title: t('plugin__gateway-api-console-plugin~Name'), + id: 'name', + sort: 'metadata.name', + }, + { + title: t('plugin__gateway-api-console-plugin~Type'), + id: 'type', + sort: 'kind', + }, + { + title: t('plugin__gateway-api-console-plugin~Namespace'), + id: 'namespace', + sort: 'metadata.namespace', + }, + { + title: t('plugin__gateway-api-console-plugin~Status'), + id: 'status', + }, + ]; + + const AttachedResourceRow: React.FC> = ({ obj, activeColumnIDs }) => { + const [group, version] = obj.apiVersion.includes('/') + ? obj.apiVersion.split('/') + : ['', obj.apiVersion]; + return ( + <> + {columns.map((column) => { + switch (column.id) { + case 'name': + return ( + + + + ); + case 'type': + return ( + + {obj.kind} + + ); + case 'namespace': + return ( + + {obj.metadata.namespace || '-'} + + ); + case 'status': + return ( + + {getStatusLabel(obj)} + + ); + default: + return null; + } + })} + + ); + }; + + const allLoaded = Object.values(watchedResources).every((res) => res.loaded); + const loadErrors = Object.values(watchedResources) + .filter((res) => res.loadError) + .map((res) => res.loadError); + const combinedLoadError = + loadErrors.length > 0 ? new Error(loadErrors.map((err) => err.message).join('; ')) : null; + + return ( +
+ {combinedLoadError && ( + + + {combinedLoadError.message} + + + )} + {attachedRoutes.length === 0 && allLoaded ? ( + + {t('No attached routes found')} + + } + icon={SearchIcon} + > + {t('No route resources matched')} + + ) : ( + + data={attachedRoutes} + unfilteredData={attachedRoutes} + loaded={allLoaded} + loadError={combinedLoadError} + columns={columns} + Row={AttachedResourceRow} + /> + )} +
+ ); +}; + +export default AttachedResources; diff --git a/src/components/GatewaySingleOverview.tsx b/src/components/GatewaySingleOverview.tsx new file mode 100644 index 0000000..7a11330 --- /dev/null +++ b/src/components/GatewaySingleOverview.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import Helmet from 'react-helmet'; +import { useTranslation } from 'react-i18next'; +import { PageSection, Title } from '@patternfly/react-core'; +import { useLocation } from 'react-router-dom'; +import { + useK8sWatchResources, + K8sResourceCommon, + useActiveNamespace, +} from '@openshift-console/dynamic-plugin-sdk'; + +import extractResourceNameFromURL from '../utils/nameFromPath'; +import AttachedResources from './AttachedResources'; + +const GatewayPoliciesPage: React.FC = () => { + const { t } = useTranslation('plugin__gateway-api-console-plugin'); + const [activeNamespace] = useActiveNamespace(); + const location = useLocation(); + + const routeName = extractResourceNameFromURL(location.pathname); + const resources = { + gateway: { + groupVersionKind: { + group: 'gateway.networking.k8s.io', + version: 'v1', + kind: 'Gateway', + }, + namespace: activeNamespace, + name: routeName, + isList: false, + }, + }; + + const watchedResources = useK8sWatchResources<{ gateway: K8sResourceCommon }>(resources); + const { loaded, loadError, data: httpRoute } = watchedResources.gateway; + + return ( + <> + + {t('Kuadrant Policies')} + + + {t('Kuadrant Policies')} + {!loaded ? ( +
Loading...
+ ) : loadError ? ( +
Error loading Gateway: {loadError.message}
+ ) : ( + + )} +
+ + ); +}; + + +export default GatewayPoliciesPage; diff --git a/src/utils/nameFromPath.ts b/src/utils/nameFromPath.ts new file mode 100644 index 0000000..885314f --- /dev/null +++ b/src/utils/nameFromPath.ts @@ -0,0 +1,26 @@ +/** + * Dynamically extracts the resource name from an OpenShift Console URL. + * + * Extracts resources names from a console path + * Matches "~v~" + * and returns the following segment as the resource name. + * + * given: + * '/k8s/ns/toystore-1/gateway.networking.k8s.io~v1~HTTPRoute/toystore/policies' + * + * it will return "toystore", regardless of resource GVK. + */ +const extractResourceNameFromURL = (pathname: string): string | null => { + const pathSegments = pathname.split('/'); + + // match "~v~" + const resourceIndex = pathSegments.findIndex((segment) => /^.+~v\d+~.+$/.test(segment)); + + if (resourceIndex !== -1 && resourceIndex + 1 < pathSegments.length) { + return pathSegments[resourceIndex + 1]; + } + + return null; +}; + +export default extractResourceNameFromURL; diff --git a/src/utils/statusLabel.tsx b/src/utils/statusLabel.tsx new file mode 100644 index 0000000..d494469 --- /dev/null +++ b/src/utils/statusLabel.tsx @@ -0,0 +1,181 @@ +import * as React from 'react'; + +import { useTranslation } from 'react-i18next'; + +import { + CheckCircleIcon, + ExclamationTriangleIcon, + UploadIcon, + PendingIcon, +} from '@patternfly/react-icons'; + +import { Label, Tooltip } from '@patternfly/react-core'; + +const generateLabelWithTooltip = (labelText, color, icon, tooltipText) => { + return ( + + + + ); +}; + +const getStatusLabel = (obj) => { + const { t } = useTranslation('plugin__gateway-api-console-plugin'); + + const tooltipTexts = { + // HTTPRoute statuses + 'Route Accepted': t('The HTTPRoute is accepted by at least one parent gateway.'), + 'Route Not Accepted': t('The HTTPRoute is not accepted by any parent gateways.'), + 'Route Unresolved Refs': t('Some references for the HTTPRoute could not be resolved.'), + + // Gateway statuses + 'Gateway Ready': t('The Gateway is accepted, programmed, and ready to serve traffic.'), + 'Gateway Programmed': t('The Gateway is accepted and programmed in the data plane.'), + 'Gateway Accepted': t('The Gateway configuration is accepted but not yet programmed.'), + 'Gateway Not Ready': t('The Gateway has issues and is not ready to serve traffic.'), + + // Common statuses + Unknown: t('The status of the resource is unknown.'), + Creating: t('The resource is being processed.'), + }; + + const { kind, status } = obj; + + // For Gateway, check the status.conditions for overall gateway status + if (kind === 'Gateway') { + const conditions = status?.conditions || []; + + // If no conditions, the Gateway is likely still being processed + if (conditions.length === 0) { + return generateLabelWithTooltip( + 'Creating', + 'cyan', + , + tooltipTexts['Creating'], + ); + } + + // Check for standard Gateway API conditions + const acceptedCondition = conditions.find( + (cond) => cond.type === 'Accepted' && cond.status === 'True', + ); + const programmedCondition = conditions.find( + (cond) => cond.type === 'Programmed' && cond.status === 'True', + ); + const readyCondition = conditions.find( + (cond) => cond.type === 'Ready' && cond.status === 'True', + ); + + // Determine status based on conditions (in order of preference) + if (readyCondition || (acceptedCondition && programmedCondition)) { + return generateLabelWithTooltip( + 'Ready', + 'green', + , + tooltipTexts['Gateway Ready'], + ); + } else if (programmedCondition) { + return generateLabelWithTooltip( + 'Programmed', + 'blue', + , + tooltipTexts['Gateway Programmed'], + ); + } else if (acceptedCondition) { + return generateLabelWithTooltip( + 'Accepted', + 'purple', + , + tooltipTexts['Gateway Accepted'], + ); + } else { + // Check for false conditions to show specific errors + const hasErrorConditions = conditions.some( + (cond) => + (cond.type === 'Accepted' || cond.type === 'Programmed' || cond.type === 'Ready') && + cond.status === 'False', + ); + + if (hasErrorConditions) { + return generateLabelWithTooltip( + 'Not Ready', + 'red', + , + tooltipTexts['Gateway Not Ready'], + ); + } else { + return generateLabelWithTooltip( + 'Unknown', + 'orange', + , + tooltipTexts['Unknown'], + ); + } + } + } + + // For HTTPRoute, check the status.parents for gateway attachment status + if (kind === 'HTTPRoute') { + const parents = status?.parents || []; + + // If no status.parents, the HTTPRoute is likely still being processed + if (parents.length === 0) { + return generateLabelWithTooltip( + 'Creating', + 'cyan', + , + tooltipTexts['Creating'], + ); + } + + // Check how many parents have accepted this HTTPRoute + const acceptedParents = parents.filter((parent) => + parent.conditions?.some((cond) => cond.type === 'Accepted' && cond.status === 'True'), + ); + + const acceptedCount = acceptedParents.length; + + // Check if all references are resolved + const hasUnresolvedRefs = parents.some((parent) => + parent.conditions?.some((cond) => cond.type === 'ResolvedRefs' && cond.status === 'False'), + ); + + if (hasUnresolvedRefs) { + return generateLabelWithTooltip( + 'Unresolved Refs', + 'orange', + , + tooltipTexts['Route Unresolved Refs'], + ); + } + + // Determine status based on acceptance rate + if (acceptedCount > 0) { + return generateLabelWithTooltip( + 'Accepted', + 'green', + , + tooltipTexts['Route Accepted'], + ); + } else { + return generateLabelWithTooltip( + 'Not Accepted', + 'red', + , + tooltipTexts['Route Not Accepted'], + ); + } + } + + // For other resource types, return unknown status + return generateLabelWithTooltip( + 'Unknown', + 'grey', + , + tooltipTexts['Unknown'], + ); +}; + +export { getStatusLabel };