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

+
## 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 };