From 1de10950640504f34d56948148e42ce849d39b06 Mon Sep 17 00:00:00 2001 From: DinithEdirisinghe Date: Fri, 15 May 2026 12:35:33 +0530 Subject: [PATCH 01/12] feat: add landing page components and localized strings for publisher API management portal --- portals/publisher/pom.xml | 2 +- .../src/main/webapp/package-lock.json | 26 +- .../publisher/src/main/webapp/package.json | 6 +- .../main/webapp/site/public/locales/en.json | 1 + .../source/src/app/components/Apis/Apis.jsx | 7 +- .../components/Apis/Discover/DiscoverAPIs.jsx | 325 ++++++++++++++++++ .../PublisherLanding/ApisSection.jsx | 36 +- 7 files changed, 376 insertions(+), 27 deletions(-) create mode 100644 portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx diff --git a/portals/publisher/pom.xml b/portals/publisher/pom.xml index f01b72f52a0..ed37eb65d75 100644 --- a/portals/publisher/pom.xml +++ b/portals/publisher/pom.xml @@ -112,7 +112,7 @@ npm - ci + install diff --git a/portals/publisher/src/main/webapp/package-lock.json b/portals/publisher/src/main/webapp/package-lock.json index 147ead45e19..563533e3703 100644 --- a/portals/publisher/src/main/webapp/package-lock.json +++ b/portals/publisher/src/main/webapp/package-lock.json @@ -15318,6 +15318,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -26887,17 +26902,6 @@ "@types/unist": "*" } }, - "node_modules/swagger-ui-react/node_modules/@types/react": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", - "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, "node_modules/swagger-ui-react/node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", diff --git a/portals/publisher/src/main/webapp/package.json b/portals/publisher/src/main/webapp/package.json index d3be3bfe36d..01a919f3f62 100644 --- a/portals/publisher/src/main/webapp/package.json +++ b/portals/publisher/src/main/webapp/package.json @@ -12,9 +12,9 @@ "test:ci2": "CI=true jest --silent --ci --watchAll=false --watch=false --coverage --coverageReporters=cobertura", "test": "DEBUG_PRINT_LIMIT=9000 jest --watch", "test:coverage": "jest --silent --coverage .; python3 -m http.server -d coverage/", - "build:prod": "cross-env NODE_ENVS=production; npm run i18n:en && rimraf site/public/dist/ && NODE_OPTIONS=--max_old_space_size=4096 webpack --mode production --stats=errors-only", - "build:dev": "NODE_ENV=development && npm run i18n:en && rimraf site/public/dist/ && webpack --mode development --watch", - "analysis": "NODE_ENVS=analysis NODE_OPTIONS=--max_old_space_size=8172 webpack --mode production --progress", + "build:prod": "cross-env NODE_ENVS=production npm run i18n:en && rimraf site/public/dist/ && cross-env NODE_OPTIONS=--max_old_space_size=4096 webpack --mode production --stats=errors-only", + "build:dev": "cross-env NODE_ENV=development npm run i18n:en && rimraf site/public/dist/ && webpack --mode development --watch", + "analysis": "cross-env NODE_ENVS=analysis NODE_OPTIONS=--max_old_space_size=8172 webpack --mode production --progress", "lint": "eslint --ignore-pattern '*.test.js' --ignore-pattern '*.test.jsx' --quiet -c .eslintrc.js --ext .jsx,.js source", "i18n": "formatjs extract 'source/src/app/**/*.{jsx,tsx}' --throws --out-file site/public/locales/raw.en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'", "extract-i18n": "formatjs extract", diff --git a/portals/publisher/src/main/webapp/site/public/locales/en.json b/portals/publisher/src/main/webapp/site/public/locales/en.json index 5268e5891ab..ea999f578cc 100644 --- a/portals/publisher/src/main/webapp/site/public/locales/en.json +++ b/portals/publisher/src/main/webapp/site/public/locales/en.json @@ -2399,6 +2399,7 @@ "Publisher.Landing.create.api.product.button": "Create API Product", "Publisher.Landing.create.mcp.button": "Create MCP Server", "Publisher.Landing.description": "Let's get started!", + "Publisher.Landing.discover.apis.button": "Discover APIs", "Publisher.Landing.error.title": "Error Loading Data", "Publisher.Landing.mcpServers.section.title": "MCP Servers", "Publisher.Landing.no.api.products.message.line1": "No API Products found.", diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx index cbf939dccf5..b814548bb28 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx @@ -24,6 +24,7 @@ import MCPRouteGuard from 'AppComponents/Shared/MCPRouteGuard'; import Listing from './Listing/Listing'; import APICreateWithAI from './Create/CreateAPIWithAI/APICreateWithAI'; +import DiscoverAPIs from './Discover/DiscoverAPIs'; /* if needs to pre fetch use 'webpackPrefetch: true' */ @@ -85,6 +86,8 @@ const Apis = () => { key={Date.now()} render={(props) => } /> + + { } }} /> - ( diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx new file mode 100644 index 00000000000..3346ae21fe8 --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -0,0 +1,325 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Button, + Checkbox, + FormControlLabel, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + CircularProgress, + Alert, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; +import AuthManager from 'AppData/AuthManager'; +import Utils from 'AppData/Utils'; +import { Link } from 'react-router-dom'; + +const Root = styled('div')(({ theme }) => ({ + padding: theme.spacing(4), + '& .header': { + marginBottom: theme.spacing(4), + }, + '& .gateway-list': { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + marginBottom: theme.spacing(4), + }, + '& .actions': { + marginTop: theme.spacing(2), + }, +})); + +const DiscoverAPIs = () => { + const { data: settings, isLoading } = usePublisherSettings(); + const [selectedGateways, setSelectedGateways] = useState([]); + const [step, setStep] = useState(1); + const [discoveredAPIs, setDiscoveredAPIs] = useState([]); + const [discovering, setDiscovering] = useState(false); + const [error, setError] = useState(null); + const [importingStates, setImportingStates] = useState({}); + + if (isLoading) { + return ; + } + + const gateways = (settings?.environment || []).filter( + (env) => !env.provider.toLowerCase().includes('wso2') + ); + + const handleToggleGateway = (gatewayName) => { + setSelectedGateways((prev) => + prev.includes(gatewayName) + ? prev.filter((g) => g !== gatewayName) + : [...prev, gatewayName] + ); + }; + + const handleDiscover = async () => { + setStep(2); + setDiscovering(true); + setError(null); + + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + + const results = await Promise.all( + selectedGateways.map(async (gw) => { + const newResponse = await fetch( + `${basePath}/federated-apis/discover/new?environment=${gw}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + const updatedResponse = await fetch( + `${basePath}/federated-apis/discover/updates?environment=${gw}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + if (!newResponse.ok || !updatedResponse.ok) { + throw new Error(`Failed to discover APIs from ${gw}`); + } + + const newData = await newResponse.json(); + const updatedData = await updatedResponse.json(); + + const newItems = newData.map((item) => ({ + ...item, + gatewayName: gw, + isUpdate: false, + })); + const updatedItems = updatedData.map((item) => ({ + ...item, + gatewayName: gw, + isUpdate: true, + })); + + return [...newItems, ...updatedItems]; + }) + ); + + // Flatten the array of arrays + setDiscoveredAPIs(results.flat()); + } catch (err) { + setError(err.message); + } finally { + setDiscovering(false); + } + }; + + const handleAction = async (api, gatewayName, isUpdate) => { + setImportingStates((prev) => ({ ...prev, [api.id]: 'importing' })); + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + const endpoint = isUpdate ? 'update' : 'import'; + + const response = await fetch(`${basePath}/federated-apis/${endpoint}?environment=${gatewayName}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify([api.id]), + }); + + if (!response.ok) { + throw new Error(`Failed to ${isUpdate ? 'update' : 'import'} API`); + } + + setImportingStates((prev) => ({ ...prev, [api.id]: 'success' })); + } catch (err) { + console.error(err); + setImportingStates((prev) => ({ ...prev, [api.id]: 'error' })); + } + }; + + const renderAction = (item) => { + const { api, gatewayName, isUpdate } = item; + const importState = importingStates[api.id]; + if (importState === 'success') { + return ( + + ); + } + if (importState === 'importing') { + return ; + } + + let buttonColor = 'primary'; + let buttonText = 'Create'; + + if (importState === 'error') { + buttonColor = 'error'; + buttonText = 'Retry'; + } else if (isUpdate) { + buttonColor = 'secondary'; + buttonText = 'Update'; + } + + return ( + + ); + }; + + return ( + +
+ Discover APIs + + Discover and import APIs from federated gateways into WSO2 API Manager. + +
+ + {error && ( + + {error} + + )} + + {step === 1 && ( + + + Select Gateways + + {gateways.length === 0 ? ( + No external gateways available. + ) : ( +
+ {gateways.map((gw) => ( + handleToggleGateway(gw.name)} + color='primary' + /> + } + label={`${gw.displayName || gw.name} (${gw.gatewayType || 'External'})`} + /> + ))} +
+ )} + +
+ + +
+
+ )} + + {step === 2 && ( + + {discovering ? ( + + + Discovering APIs... + + ) : ( + <> + + + Discovered APIs ({discoveredAPIs.length}) + + + + + {discoveredAPIs.length === 0 ? ( + + No new APIs discovered from the selected gateways. + + ) : ( + + + + + API Name + Version + Description + Context + Gateway Name + Gateway Type + Discovered At + Actions + + + + {discoveredAPIs.map((item) => { + const { api } = item; + const gwData = gateways.find((g) => g.name === item.gatewayName); + const gwType = gwData ? gwData.gatewayType : 'External'; + + return ( + + {api.name} + {api.version} + {api.description || '-'} + {api.context} + {item.gatewayName} + {gwType} + {new Date().toLocaleString()} + + {renderAction(item)} + + + ); + })} + +
+
+ )} + + )} +
+ )} +
+ ); +}; + +export default DiscoverAPIs; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx b/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx index b41fe167839..281d8ff3d45 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx @@ -23,6 +23,7 @@ import { Link } from 'react-router-dom'; import AddIcon from '@mui/icons-material/Add'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import ExploreIcon from '@mui/icons-material/Explore'; import Configurations from 'Config'; import { isRestricted } from 'AppData/AuthManager'; import CONSTS from 'AppData/Constants'; @@ -114,16 +115,31 @@ const ApisSection = ({ data, totalCount, onDelete }) => { - + + + + {data.length === 0 ? ( Date: Wed, 3 Jun 2026 09:51:44 +0530 Subject: [PATCH 02/12] Async implementation --- .../components/Apis/Discover/DiscoverAPIs.jsx | 711 ++++++++++-------- 1 file changed, 386 insertions(+), 325 deletions(-) diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx index 3346ae21fe8..d7736ef61ef 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -1,325 +1,386 @@ -import React, { useState } from 'react'; -import { - Box, - Typography, - Button, - Checkbox, - FormControlLabel, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - CircularProgress, - Alert, -} from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; -import AuthManager from 'AppData/AuthManager'; -import Utils from 'AppData/Utils'; -import { Link } from 'react-router-dom'; - -const Root = styled('div')(({ theme }) => ({ - padding: theme.spacing(4), - '& .header': { - marginBottom: theme.spacing(4), - }, - '& .gateway-list': { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), - marginBottom: theme.spacing(4), - }, - '& .actions': { - marginTop: theme.spacing(2), - }, -})); - -const DiscoverAPIs = () => { - const { data: settings, isLoading } = usePublisherSettings(); - const [selectedGateways, setSelectedGateways] = useState([]); - const [step, setStep] = useState(1); - const [discoveredAPIs, setDiscoveredAPIs] = useState([]); - const [discovering, setDiscovering] = useState(false); - const [error, setError] = useState(null); - const [importingStates, setImportingStates] = useState({}); - - if (isLoading) { - return ; - } - - const gateways = (settings?.environment || []).filter( - (env) => !env.provider.toLowerCase().includes('wso2') - ); - - const handleToggleGateway = (gatewayName) => { - setSelectedGateways((prev) => - prev.includes(gatewayName) - ? prev.filter((g) => g !== gatewayName) - : [...prev, gatewayName] - ); - }; - - const handleDiscover = async () => { - setStep(2); - setDiscovering(true); - setError(null); - - try { - const token = AuthManager.getUser().getPartialToken(); - const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); - - const results = await Promise.all( - selectedGateways.map(async (gw) => { - const newResponse = await fetch( - `${basePath}/federated-apis/discover/new?environment=${gw}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - } - ); - - const updatedResponse = await fetch( - `${basePath}/federated-apis/discover/updates?environment=${gw}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - } - ); - - if (!newResponse.ok || !updatedResponse.ok) { - throw new Error(`Failed to discover APIs from ${gw}`); - } - - const newData = await newResponse.json(); - const updatedData = await updatedResponse.json(); - - const newItems = newData.map((item) => ({ - ...item, - gatewayName: gw, - isUpdate: false, - })); - const updatedItems = updatedData.map((item) => ({ - ...item, - gatewayName: gw, - isUpdate: true, - })); - - return [...newItems, ...updatedItems]; - }) - ); - - // Flatten the array of arrays - setDiscoveredAPIs(results.flat()); - } catch (err) { - setError(err.message); - } finally { - setDiscovering(false); - } - }; - - const handleAction = async (api, gatewayName, isUpdate) => { - setImportingStates((prev) => ({ ...prev, [api.id]: 'importing' })); - try { - const token = AuthManager.getUser().getPartialToken(); - const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); - const endpoint = isUpdate ? 'update' : 'import'; - - const response = await fetch(`${basePath}/federated-apis/${endpoint}?environment=${gatewayName}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify([api.id]), - }); - - if (!response.ok) { - throw new Error(`Failed to ${isUpdate ? 'update' : 'import'} API`); - } - - setImportingStates((prev) => ({ ...prev, [api.id]: 'success' })); - } catch (err) { - console.error(err); - setImportingStates((prev) => ({ ...prev, [api.id]: 'error' })); - } - }; - - const renderAction = (item) => { - const { api, gatewayName, isUpdate } = item; - const importState = importingStates[api.id]; - if (importState === 'success') { - return ( - - ); - } - if (importState === 'importing') { - return ; - } - - let buttonColor = 'primary'; - let buttonText = 'Create'; - - if (importState === 'error') { - buttonColor = 'error'; - buttonText = 'Retry'; - } else if (isUpdate) { - buttonColor = 'secondary'; - buttonText = 'Update'; - } - - return ( - - ); - }; - - return ( - -
- Discover APIs - - Discover and import APIs from federated gateways into WSO2 API Manager. - -
- - {error && ( - - {error} - - )} - - {step === 1 && ( - - - Select Gateways - - {gateways.length === 0 ? ( - No external gateways available. - ) : ( -
- {gateways.map((gw) => ( - handleToggleGateway(gw.name)} - color='primary' - /> - } - label={`${gw.displayName || gw.name} (${gw.gatewayType || 'External'})`} - /> - ))} -
- )} - -
- - -
-
- )} - - {step === 2 && ( - - {discovering ? ( - - - Discovering APIs... - - ) : ( - <> - - - Discovered APIs ({discoveredAPIs.length}) - - - - - {discoveredAPIs.length === 0 ? ( - - No new APIs discovered from the selected gateways. - - ) : ( - - - - - API Name - Version - Description - Context - Gateway Name - Gateway Type - Discovered At - Actions - - - - {discoveredAPIs.map((item) => { - const { api } = item; - const gwData = gateways.find((g) => g.name === item.gatewayName); - const gwType = gwData ? gwData.gatewayType : 'External'; - - return ( - - {api.name} - {api.version} - {api.description || '-'} - {api.context} - {item.gatewayName} - {gwType} - {new Date().toLocaleString()} - - {renderAction(item)} - - - ); - })} - -
-
- )} - - )} -
- )} -
- ); -}; - -export default DiscoverAPIs; +import React, { useState } from 'react'; +import { + Box, + Typography, + Button, + Checkbox, + FormControlLabel, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + CircularProgress, + Alert, + Chip, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; +import AuthManager from 'AppData/AuthManager'; +import Utils from 'AppData/Utils'; +import { Link } from 'react-router-dom'; + +const Root = styled('div')(({ theme }) => ({ + padding: theme.spacing(4), + '& .header': { + marginBottom: theme.spacing(4), + }, + '& .gateway-list': { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + marginBottom: theme.spacing(4), + }, + '& .actions': { + marginTop: theme.spacing(2), + }, +})); + +// How often (ms) to poll the status endpoint while a task is PENDING +const POLL_INTERVAL_MS = 2000; +// Maximum time (ms) to wait before giving up on a task +const POLL_TIMEOUT_MS = 120000; + +/** + * Single poll attempt: checks GET /federated-apis/status/{taskId} once. + * Returns the result array if COMPLETED, throws if FAILED, or returns null if still PENDING. + */ +const pollOnce = (taskId, basePath, token) => { + return fetch(`${basePath}/federated-apis/status/${taskId}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }).then((res) => { + if (!res.ok) { + throw new Error(`Status check failed for task ${taskId} (HTTP ${res.status})`); + } + return res.json(); + }).then((data) => { + if (data.status === 'COMPLETED') { + return data.result || []; + } + if (data.status === 'FAILED') { + throw new Error(data.error || `Task ${taskId} failed on the server.`); + } + return null; // still PENDING + }); +}; + +/** + * Polls GET /federated-apis/status/{taskId} using tail-recursive promises until + * the task is COMPLETED or FAILED, or until the timeout is reached. + * + * @returns {Promise} The flat API list from the COMPLETED task result. + */ +const pollTaskStatus = (taskId, basePath, token, startTime = Date.now()) => { + if (Date.now() - startTime >= POLL_TIMEOUT_MS) { + return Promise.reject( + new Error(`Discovery timed out after ${POLL_TIMEOUT_MS / 1000}s for task ${taskId}.`) + ); + } + return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + .then(() => pollOnce(taskId, basePath, token)) + .then((result) => { + if (result !== null) { + return result; // COMPLETED + } + // Still PENDING — recurse + return pollTaskStatus(taskId, basePath, token, startTime); + }); +}; + + +const DiscoverAPIs = () => { + const { data: settings, isLoading } = usePublisherSettings(); + const [selectedGateways, setSelectedGateways] = useState([]); + const [step, setStep] = useState(1); + const [discoveredAPIs, setDiscoveredAPIs] = useState([]); + const [discovering, setDiscovering] = useState(false); + const [discoveringStatus, setDiscoveringStatus] = useState(''); + const [error, setError] = useState(null); + const [importingStates, setImportingStates] = useState({}); + + if (isLoading) { + return ; + } + + const gateways = (settings?.environment || []).filter( + (env) => !env.provider.toLowerCase().includes('wso2') + ); + + const handleToggleGateway = (gatewayName) => { + setSelectedGateways((prev) => + prev.includes(gatewayName) + ? prev.filter((g) => g !== gatewayName) + : [...prev, gatewayName] + ); + }; + + const handleDiscover = async () => { + setStep(2); + setDiscovering(true); + setError(null); + setDiscoveredAPIs([]); + + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + + const results = await Promise.all( + selectedGateways.map(async (gw) => { + // Step 1: Submit the async discovery task — server returns 202 with {taskId, status} + setDiscoveringStatus(`Submitting discovery for ${gw}...`); + const submitResponse = await fetch( + `${basePath}/federated-apis/discover?environment=${gw}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + // 202 is success for async submission + if (!submitResponse.ok && submitResponse.status !== 202) { + throw new Error(`Failed to start discovery for ${gw} (HTTP ${submitResponse.status})`); + } + + const submitData = await submitResponse.json(); + const { taskId } = submitData; + + if (!taskId) { + throw new Error(`No task ID returned for gateway ${gw}`); + } + + // Step 2: Poll GET /status/{taskId} until COMPLETED or FAILED + setDiscoveringStatus(`Discovering APIs from ${gw} (task: ${taskId.substring(0, 8)}...)...`); + const apiList = await pollTaskStatus(taskId, basePath, token); + + // Backend returns a flat array: [{apiName, version, status, gatewayName, ...}] + return apiList; + }) + ); + + // Flatten results from all selected gateways into one list + setDiscoveredAPIs(results.flat()); + } catch (err) { + setError(err.message); + } finally { + setDiscovering(false); + setDiscoveringStatus(''); + } + }; + + const handleAction = async (item) => { + const apiId = item.id; + const { gatewayName } = item; + const isUpdate = item.status === 'UPDATE'; + + setImportingStates((prev) => ({ ...prev, [apiId]: 'importing' })); + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + const endpoint = isUpdate ? 'update' : 'import'; + + const response = await fetch(`${basePath}/federated-apis/${endpoint}?environment=${gatewayName}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify([apiId]), + }); + + if (!response.ok) { + throw new Error(`Failed to ${isUpdate ? 'update' : 'import'} API`); + } + + setImportingStates((prev) => ({ ...prev, [apiId]: 'success' })); + } catch (err) { + console.error(err); + setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); + } + }; + + const renderAction = (item) => { + const apiId = item.id; + const isUpdate = item.status === 'UPDATE'; + const importState = importingStates[apiId]; + + if (importState === 'success') { + return ( + + ); + } + if (importState === 'importing') { + return ; + } + + let buttonColor = 'primary'; + let buttonText = 'Import'; + + if (importState === 'error') { + buttonColor = 'error'; + buttonText = 'Retry'; + } else if (isUpdate) { + buttonColor = 'secondary'; + buttonText = 'Update'; + } + + return ( + + ); + }; + + return ( + +
+ Discover APIs + + Discover and import APIs from federated gateways into WSO2 API Manager. + +
+ + {error && ( + + {error} + + )} + + {step === 1 && ( + + + Select Gateways + + {gateways.length === 0 ? ( + No external gateways available. + ) : ( +
+ {gateways.map((gw) => ( + handleToggleGateway(gw.name)} + color='primary' + /> + } + label={`${gw.displayName || gw.name} (${gw.gatewayType || 'External'})`} + /> + ))} +
+ )} + +
+ + +
+
+ )} + + {step === 2 && ( + + {discovering ? ( + + + + {discoveringStatus || 'Discovering APIs...'} + + + ) : ( + <> + + + Discovered APIs ({discoveredAPIs.length}) + + + + + {discoveredAPIs.length === 0 ? ( + + No new APIs discovered from the selected gateways. + + ) : ( + + + + + API Name + Version + Description + Context + Gateway + Status + Discovered At + Actions + + + + {discoveredAPIs.map((item) => ( + + {item.apiName} + {item.version} + {item.description || '-'} + {item.context || '-'} + {item.gatewayName} + + + + + {item.discoveredAt + ? new Date(item.discoveredAt).toLocaleString() + : new Date().toLocaleString()} + + + {renderAction(item)} + + + ))} + +
+
+ )} + + )} +
+ )} +
+ ); +}; + +export default DiscoverAPIs; From 32bc2bfec30d8f20b4c674facb77baec762873fa Mon Sep 17 00:00:00 2001 From: DinithEdirisinghe Date: Sat, 6 Jun 2026 20:26:54 +0530 Subject: [PATCH 03/12] can switch between schedular and on demand but still the disovery button is there in the publisher UI --- .../source/src/app/components/Apis/Apis.jsx | 12 +++- .../PublisherLanding/ApisSection.jsx | 31 +++++----- .../FederatedAPIDiscoveryRouteGuard.jsx | 57 +++++++++++++++++++ 3 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 portals/publisher/src/main/webapp/source/src/app/components/Shared/FederatedAPIDiscoveryRouteGuard.jsx diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx index b814548bb28..90ec88dbfac 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx @@ -21,6 +21,7 @@ import { Route, Switch, Redirect } from 'react-router-dom'; import Progress from 'AppComponents/Shared/Progress'; import AuthManager from 'AppData/AuthManager'; import MCPRouteGuard from 'AppComponents/Shared/MCPRouteGuard'; +import FederatedAPIDiscoveryRouteGuard from 'AppComponents/Shared/FederatedAPIDiscoveryRouteGuard'; import Listing from './Listing/Listing'; import APICreateWithAI from './Create/CreateAPIWithAI/APICreateWithAI'; @@ -86,7 +87,16 @@ const Apis = () => { key={Date.now()} render={(props) => } /> - + ( + + + + )} + /> diff --git a/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx b/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx index 281d8ff3d45..a63b1e20f92 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx @@ -27,6 +27,7 @@ import ExploreIcon from '@mui/icons-material/Explore'; import Configurations from 'Config'; import { isRestricted } from 'AppData/AuthManager'; import CONSTS from 'AppData/Constants'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; import DataTable from './DataTable'; const PREFIX = 'ApisSection'; @@ -106,6 +107,8 @@ const Root = styled('div')(({ theme }) => ({ const ApisSection = ({ data, totalCount, onDelete }) => { const theme = useTheme(); const { noDataIcon } = theme.custom.landingPage.icons; + const { data: settings } = usePublisherSettings(); + const isFederatedAPIDiscoveryEnabled = settings && settings.isFederatedAPIDiscoveryEnabled; return (
@@ -116,19 +119,21 @@ const ApisSection = ({ data, totalCount, onDelete }) => {
- + {isFederatedAPIDiscoveryEnabled && ( + + )} - {discoveredAPIs.length === 0 ? ( - + {totalApisCount === 0 && !hasErrors ? ( + No new APIs discovered from the selected gateways. ) : ( - - - - - API Name - Version - Description - Context - Gateway - Status - Discovered At - Actions - - - - {discoveredAPIs.map((item) => ( - - {item.apiName} - {item.version} - {item.description || '-'} - {item.context || '-'} - {item.gatewayName} - + + {Object.entries(discoveryResults).map(([gwName, res]) => { + const isDone = res.status === 'success'; + return ( + + + + Gateway: {gwName} + + {isDone ? ( + + ) : ( - - - {item.discoveredAt - ? new Date(item.discoveredAt).toLocaleString() - : new Date().toLocaleString()} - - - {renderAction(item)} - - - ))} - -
-
+ )} + + {renderGatewayResults(gwName, res)} + + ); + })} + )} )} From 18372e1266daa23a77ca8bf75471ab37594d03f9 Mon Sep 17 00:00:00 2001 From: DinithEdirisinghe Date: Thu, 11 Jun 2026 17:16:17 +0530 Subject: [PATCH 05/12] search option, pagination, retry option --- .../components/Apis/Discover/DiscoverAPIs.jsx | 251 ++++++++++++++---- 1 file changed, 205 insertions(+), 46 deletions(-) diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx index e55216f964c..d87cf0428fb 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -15,7 +15,12 @@ import { CircularProgress, Alert, Chip, + TextField, + TablePagination, + InputAdornment, } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import RefreshIcon from '@mui/icons-material/Refresh'; import { styled } from '@mui/material/styles'; import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; import AuthManager from 'AppData/AuthManager'; @@ -113,6 +118,9 @@ const DiscoverAPIs = () => { const [discoveryResults, setDiscoveryResults] = useState({}); const [discovering, setDiscovering] = useState(false); const [error, setError] = useState(null); + const [searchQueries, setSearchQueries] = useState({}); + const [pages, setPages] = useState({}); + const [rowsPerPage, setRowsPerPage] = useState({}); const [importingStates, setImportingStates] = useState({}); if (isLoading) { @@ -131,10 +139,71 @@ const DiscoverAPIs = () => { ); }; + const handleRetryGateway = async (gw) => { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { + status: 'pending', + statusText: 'Retrying discovery...', + apis: [], + }, + })); + + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + + const submitResponse = await fetch( + `${basePath}/federated-apis/discover?environment=${gw}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + if (!submitResponse.ok && submitResponse.status !== 202) { + throw new Error(`Failed to start discovery (HTTP ${submitResponse.status})`); + } + + const submitData = await submitResponse.json(); + const { taskId } = submitData; + + if (!taskId) { + throw new Error('No task ID returned'); + } + + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { + ...prev[gw], + statusText: `Polling task ${taskId.substring(0, 8)}...`, + }, + })); + + const apiList = await pollTaskStatus(taskId, basePath, token); + + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'success', apis: apiList }, + })); + } catch (err) { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'error', error: err.message, apis: [] }, + })); + } + }; + const handleDiscover = async () => { setStep(2); setDiscovering(true); setError(null); + setSearchQueries({}); + setPages({}); + setRowsPerPage({}); // Initialize results with pending state for all selected gateways const initialResults = {}; @@ -288,11 +357,42 @@ const DiscoverAPIs = () => { }; const renderGatewayResults = (gwName, res) => { + if (res.status === 'pending') { + return ( + + + {res.statusText || 'Discovering...'} + + ); + } + if (res.status === 'error') { return ( - - {res.error || 'Unknown error occurred during discovery.'} - + + + {res.error || 'Unknown error occurred during discovery.'} + + + ); } @@ -306,49 +406,107 @@ const DiscoverAPIs = () => { ); } + const query = (searchQueries[gwName] || '').toLowerCase(); + const filteredApis = res.apis.filter((item) => + (item.apiName && item.apiName.toLowerCase().includes(query)) || + (item.version && item.version.toLowerCase().includes(query)) || + (item.description && item.description.toLowerCase().includes(query)) || + (item.context && item.context.toLowerCase().includes(query)) + ); + + const page = pages[gwName] || 0; + const rpp = rowsPerPage[gwName] || 5; + const paginatedApis = filteredApis.slice(page * rpp, page * rpp + rpp); + return ( - - - - - API Name - Version - Description - Context - Status - Discovered At - Actions - - - - {res.apis.map((item) => { - const isNew = item.status === 'NEW'; - const date = item.discoveredAt - ? new Date(item.discoveredAt) - : new Date(); - const dateStr = date.toLocaleString(); - - return ( - - {item.apiName} - {item.version} - {item.description || '-'} - {item.context || '-'} - - - - {dateStr} - {renderAction(item)} - - ); - })} - -
-
+ + + { + setSearchQueries(prev => ({ ...prev, [gwName]: e.target.value })); + setPages(prev => ({ ...prev, [gwName]: 0 })); + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ width: 250 }} + /> + + + {filteredApis.length === 0 ? ( + + + No matching discovered APIs found. + + + ) : ( + <> + + + + + API Name + Version + Description + Context + Status + Discovered At + Actions + + + + {paginatedApis.map((item) => { + const isNew = item.status === 'NEW'; + const date = item.discoveredAt + ? new Date(item.discoveredAt) + : new Date(); + const dateStr = date.toLocaleString(); + + return ( + + {item.apiName} + {item.version} + {item.description || '-'} + {item.context || '-'} + + + + {dateStr} + {renderAction(item)} + + ); + })} + +
+
+ { + setPages(prev => ({ ...prev, [gwName]: newPage })); + }} + onRowsPerPageChange={(event) => { + setRowsPerPage(prev => ({ ...prev, [gwName]: parseInt(event.target.value, 10) })); + setPages(prev => ({ ...prev, [gwName]: 0 })); + }} + /> + + )} +
); }; @@ -358,6 +516,7 @@ const DiscoverAPIs = () => { ); const hasErrors = Object.values(discoveryResults).some((r) => r.status === 'error'); + const isAnyPending = Object.values(discoveryResults).some((r) => r.status === 'pending'); return ( @@ -495,7 +654,7 @@ const DiscoverAPIs = () => { - {totalApisCount === 0 && !hasErrors ? ( + {totalApisCount === 0 && !hasErrors && !isAnyPending ? ( No new APIs discovered from the selected gateways. From 3e230d55e863fed136315f9dff972d5b7f6d6fde Mon Sep 17 00:00:00 2001 From: DinithEdirisinghe Date: Tue, 16 Jun 2026 12:11:55 +0530 Subject: [PATCH 06/12] feat: implement DiscoverAPIs component with federated API discovery and polling logic --- .../components/Apis/Discover/DiscoverAPIs.jsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx index d87cf0428fb..f3a1b52bb9c 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -340,7 +340,7 @@ const DiscoverAPIs = () => { buttonColor = 'error'; buttonText = 'Retry'; } else if (isUpdate) { - buttonColor = 'secondary'; + buttonColor = 'success'; buttonText = 'Update'; } @@ -456,6 +456,7 @@ const DiscoverAPIs = () => { Version Description Context + API Type / Protocol Status Discovered At Actions @@ -475,10 +476,11 @@ const DiscoverAPIs = () => { {item.version} {item.description || '-'} {item.context || '-'} + {item.apiType || 'HTTP'} @@ -614,6 +616,9 @@ const DiscoverAPIs = () => { displayStatusText = result.error; } + const gwObj = gateways.find((g) => g.name === gw); + const gwType = gwObj ? (gwObj.gatewayType || 'External').toUpperCase() : ''; + return ( { {result.status === 'error' && ( )} - {gw} + + {gw}{gwType ? ` (${gwType})` : ''} + {displayStatusText} @@ -662,11 +669,13 @@ const DiscoverAPIs = () => { {Object.entries(discoveryResults).map(([gwName, res]) => { const isDone = res.status === 'success'; + const gwObj = gateways.find((g) => g.name === gwName); + const gwType = gwObj ? (gwObj.gatewayType || 'External').toUpperCase() : ''; return ( - Gateway: {gwName} + Gateway: {gwName}{gwType ? ` (${gwType})` : ''} {isDone ? ( Date: Fri, 19 Jun 2026 13:57:21 +0530 Subject: [PATCH 07/12] feat: implement federated API discovery result viewer with polling and import functionality --- .../main/webapp/site/public/locales/en.json | 2 + .../source/src/app/components/Apis/Apis.jsx | 11 + .../components/Apis/Discover/DiscoverAPIs.jsx | 773 +++--------- .../Apis/Discover/DiscoveryResults.jsx | 1051 +++++++++++++++++ .../Apis/Listing/components/TopMenu.jsx | 64 +- 5 files changed, 1260 insertions(+), 641 deletions(-) create mode 100644 portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx diff --git a/portals/publisher/src/main/webapp/site/public/locales/en.json b/portals/publisher/src/main/webapp/site/public/locales/en.json index ea999f578cc..28443bd0c7f 100644 --- a/portals/publisher/src/main/webapp/site/public/locales/en.json +++ b/portals/publisher/src/main/webapp/site/public/locales/en.json @@ -2004,6 +2004,7 @@ "Apis.Listing.ApiThumb.version": "Version", "Apis.Listing.SampleAPI.SampleAPI.ai.api.create.title": "Create AI API", "Apis.Listing.SampleAPI.SampleAPI.ai.api.import.content": "Create AI APIs by importing service provider APIs", + "Apis.Listing.SampleAPI.SampleAPI.back.to.gateways": "Back to Gateway Selection", "Apis.Listing.SampleAPI.SampleAPI.back.to.listing": "Back to API Listing", "Apis.Listing.SampleAPI.SampleAPI.create.new": "Let’s get started!", "Apis.Listing.SampleAPI.SampleAPI.create.new.description": "Choose your option to create an API", @@ -2079,6 +2080,7 @@ "Apis.Listing.components.TopMenu.create.an.api.product": "Create API Product", "Apis.Listing.components.TopMenu.create.api": "Create API", "Apis.Listing.components.TopMenu.create.api.with.ai": "Create API with AI", + "Apis.Listing.components.TopMenu.discover.apis.tooltip": "Discover and import APIs from your third party gateways", "Apis.Listing.components.TopMenu.displaying": "Total:", "Apis.Listing.components.TopMenu.mcp.server.singular": "MCP Server", "Apis.Listing.components.TopMenu.mcp.servers": "MCP Servers", diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx index 90ec88dbfac..29b3b26cb4c 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx @@ -26,6 +26,7 @@ import FederatedAPIDiscoveryRouteGuard from 'AppComponents/Shared/FederatedAPIDi import Listing from './Listing/Listing'; import APICreateWithAI from './Create/CreateAPIWithAI/APICreateWithAI'; import DiscoverAPIs from './Discover/DiscoverAPIs'; +import DiscoveryResults from './Discover/DiscoveryResults'; /* if needs to pre fetch use 'webpackPrefetch: true' */ @@ -97,6 +98,16 @@ const Apis = () => { )} /> + ( + + + + )} + /> diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx index f3a1b52bb9c..642dc91694c 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -1,10 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import React, { useState } from 'react'; import { Box, Typography, Button, Checkbox, - FormControlLabel, Paper, Table, TableBody, @@ -13,115 +30,34 @@ import { TableHead, TableRow, CircularProgress, - Alert, - Chip, - TextField, - TablePagination, - InputAdornment, + Link as MuiLink, + Tooltip, } from '@mui/material'; -import SearchIcon from '@mui/icons-material/Search'; -import RefreshIcon from '@mui/icons-material/Refresh'; import { styled } from '@mui/material/styles'; import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; -import AuthManager from 'AppData/AuthManager'; -import Utils from 'AppData/Utils'; import { Link } from 'react-router-dom'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { useBackNavigation } from 'AppComponents/Shared'; +import { FormattedMessage } from 'react-intl'; const Root = styled('div')(({ theme }) => ({ padding: theme.spacing(4), '& .header': { marginBottom: theme.spacing(4), }, - '& .gateway-list': { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), - marginBottom: theme.spacing(4), - }, '& .actions': { marginTop: theme.spacing(2), }, })); -// How often (ms) to poll the status endpoint while a task is PENDING -const POLL_INTERVAL_MS = 2000; -// Maximum time (ms) to wait before giving up on a task -const POLL_TIMEOUT_MS = 120000; - -/** - * Single poll attempt: checks GET /federated-apis/status/{taskId} once. - * Returns the result array if COMPLETED, throws if FAILED, or returns null if still PENDING. - */ -const pollOnce = (taskId, basePath, token) => { - return fetch(`${basePath}/federated-apis/status/${taskId}`, { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - }).then((res) => { - if (!res.ok) { - throw new Error(`Status check failed for task ${taskId} (HTTP ${res.status})`); - } - return res.json(); - }).then((data) => { - if (data.status === 'COMPLETED') { - return data.result || []; - } - if (data.status === 'FAILED') { - throw new Error(data.error || `Task ${taskId} failed on the server.`); - } - return null; // still PENDING - }); -}; - -/** - * Polls GET /federated-apis/status/{taskId} using tail-recursive promises until - * the task is COMPLETED or FAILED, or until the timeout is reached. - * - * @returns {Promise} The flat API list from the COMPLETED task result. - */ -const pollTaskStatus = (taskId, basePath, token, startTime = Date.now()) => { - if (Date.now() - startTime >= POLL_TIMEOUT_MS) { - return Promise.reject( - new Error(`Discovery timed out after ${POLL_TIMEOUT_MS / 1000}s for task ${taskId}.`) - ); - } - return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) - .then(() => pollOnce(taskId, basePath, token)) - .then((result) => { - if (result !== null) { - return result; // COMPLETED - } - // Still PENDING — recurse - return pollTaskStatus(taskId, basePath, token, startTime); - }); -}; - - -const groupGatewaysByType = (gatewaysList) => { - const grouped = {}; - gatewaysList.forEach((gw) => { - const type = (gw.gatewayType || 'External').toUpperCase(); - if (!grouped[type]) { - grouped[type] = []; - } - grouped[type].push(gw); - }); - return grouped; -}; - - -const DiscoverAPIs = () => { +const DiscoverAPIs = (props) => { + const { history, location } = props; const { data: settings, isLoading } = usePublisherSettings(); - const [selectedGateways, setSelectedGateways] = useState([]); - const [step, setStep] = useState(1); - const [discoveryResults, setDiscoveryResults] = useState({}); - const [discovering, setDiscovering] = useState(false); - const [error, setError] = useState(null); - const [searchQueries, setSearchQueries] = useState({}); - const [pages, setPages] = useState({}); - const [rowsPerPage, setRowsPerPage] = useState({}); - const [importingStates, setImportingStates] = useState({}); + const handleBackClick = useBackNavigation('/apis'); + + // Initialize selectedGateways from history location state if navigating back + const initialSelected = (location.state && location.state.selectedGateways) || []; + const [selectedGateways, setSelectedGateways] = useState(initialSelected); if (isLoading) { return ; @@ -139,570 +75,153 @@ const DiscoverAPIs = () => { ); }; - const handleRetryGateway = async (gw) => { - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { - status: 'pending', - statusText: 'Retrying discovery...', - apis: [], - }, - })); - - try { - const token = AuthManager.getUser().getPartialToken(); - const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); - - const submitResponse = await fetch( - `${basePath}/federated-apis/discover?environment=${gw}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - } - ); - - if (!submitResponse.ok && submitResponse.status !== 202) { - throw new Error(`Failed to start discovery (HTTP ${submitResponse.status})`); - } - - const submitData = await submitResponse.json(); - const { taskId } = submitData; - - if (!taskId) { - throw new Error('No task ID returned'); - } - - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { - ...prev[gw], - statusText: `Polling task ${taskId.substring(0, 8)}...`, - }, - })); - - const apiList = await pollTaskStatus(taskId, basePath, token); - - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { status: 'success', apis: apiList }, - })); - } catch (err) { - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { status: 'error', error: err.message, apis: [] }, - })); + const handleSelectAllGateways = (event) => { + if (event.target.checked) { + setSelectedGateways(gateways.map((gw) => gw.name)); + } else { + setSelectedGateways([]); } }; - const handleDiscover = async () => { - setStep(2); - setDiscovering(true); - setError(null); - setSearchQueries({}); - setPages({}); - setRowsPerPage({}); - - // Initialize results with pending state for all selected gateways - const initialResults = {}; - selectedGateways.forEach((gw) => { - initialResults[gw] = { - status: 'pending', - statusText: 'Initializing...', - apis: [], - }; + const handleDiscover = () => { + history.push({ + pathname: '/apis/discover/apis', + state: { selectedGateways }, }); - setDiscoveryResults(initialResults); - - try { - const token = AuthManager.getUser().getPartialToken(); - const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); - - await Promise.all( - selectedGateways.map(async (gw) => { - try { - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { - ...prev[gw], - statusText: `Submitting task for ${gw}...`, - }, - })); - - // Submit the async discovery task - const submitResponse = await fetch( - `${basePath}/federated-apis/discover?environment=${gw}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - } - ); - - if (!submitResponse.ok && submitResponse.status !== 202) { - throw new Error(`Failed to start discovery (HTTP ${submitResponse.status})`); - } - - const submitData = await submitResponse.json(); - const { taskId } = submitData; - - if (!taskId) { - throw new Error('No task ID returned'); - } - - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { - ...prev[gw], - statusText: `Polling task ${taskId.substring(0, 8)}...`, - }, - })); - - // Poll status - const apiList = await pollTaskStatus(taskId, basePath, token); - - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { status: 'success', apis: apiList }, - })); - } catch (err) { - setDiscoveryResults((prev) => ({ - ...prev, - [gw]: { status: 'error', error: err.message, apis: [] }, - })); - } - }) - ); - } catch (err) { - setError(err.message); - } finally { - setDiscovering(false); - } }; - const handleAction = async (item) => { - const apiId = item.id; - const { gatewayName } = item; - const isUpdate = item.status === 'UPDATE'; - - setImportingStates((prev) => ({ ...prev, [apiId]: 'importing' })); - try { - const token = AuthManager.getUser().getPartialToken(); - const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); - const endpoint = isUpdate ? 'update' : 'import'; - - const url = `${basePath}/federated-apis/${endpoint}?environment=${gatewayName}`; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify([apiId]), - }); - - if (!response.ok) { - throw new Error(`Failed to ${isUpdate ? 'update' : 'import'} API`); - } - - setImportingStates((prev) => ({ ...prev, [apiId]: 'success' })); - } catch (err) { - console.error(err); - setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); - } - }; - - const renderAction = (item) => { - const apiId = item.id; - const isUpdate = item.status === 'UPDATE'; - const importState = importingStates[apiId]; - - if (importState === 'success') { - return ( - - ); - } - if (importState === 'importing') { - return ; - } - - let buttonColor = 'primary'; - let buttonText = 'Import'; - - if (importState === 'error') { - buttonColor = 'error'; - buttonText = 'Retry'; - } else if (isUpdate) { - buttonColor = 'success'; - buttonText = 'Update'; - } - - return ( - - ); - }; + +
+ Discover APIs + + Discover and import APIs from federated gateways into WSO2 API Manager. + +
- const renderGatewayResults = (gwName, res) => { - if (res.status === 'pending') { - return ( - + {gateways.length === 0 ? ( + - - {res.statusText || 'Discovering...'} - - ); - } - - if (res.status === 'error') { - return ( - - - {res.error || 'Unknown error occurred during discovery.'} - - - - ); - } - - if (res.apis.length === 0) { - return ( - - - No new or updated APIs discovered from this gateway. - - - ); - } - - const query = (searchQueries[gwName] || '').toLowerCase(); - const filteredApis = res.apis.filter((item) => - (item.apiName && item.apiName.toLowerCase().includes(query)) || - (item.version && item.version.toLowerCase().includes(query)) || - (item.description && item.description.toLowerCase().includes(query)) || - (item.context && item.context.toLowerCase().includes(query)) - ); - - const page = pages[gwName] || 0; - const rpp = rowsPerPage[gwName] || 5; - const paginatedApis = filteredApis.slice(page * rpp, page * rpp + rpp); - - return ( - - - { - setSearchQueries(prev => ({ ...prev, [gwName]: e.target.value })); - setPages(prev => ({ ...prev, [gwName]: 0 })); - }} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ width: 250 }} - /> - - - {filteredApis.length === 0 ? ( - - - No matching discovered APIs found. + justifyContent: 'center', + py: 8, + textAlign: 'center', + }}> + + No available gateways + + + There are no external/federated gateways configured in the system. + {' '} + You can add them from the Admin portal. - + + Learn more + + ) : ( <> - - + + Select Gateways + + +
- API Name - Version - Description - Context - API Type / Protocol - Status - Discovered At - Actions + + 0 + && selectedGateways.length < gateways.length + } + checked={ + gateways.length > 0 + && selectedGateways.length === gateways.length + } + onChange={handleSelectAllGateways} + color='primary' + /> + + Gateway Name + Gateway Type - {paginatedApis.map((item) => { - const isNew = item.status === 'NEW'; - const date = item.discoveredAt - ? new Date(item.discoveredAt) - : new Date(); - const dateStr = date.toLocaleString(); - + {gateways.map((gw) => { + const isSelected = selectedGateways.includes(gw.name); return ( - - {item.apiName} - {item.version} - {item.description || '-'} - {item.context || '-'} - {item.apiType || 'HTTP'} - - handleToggleGateway(gw.name)} + role='checkbox' + aria-checked={isSelected} + selected={isSelected} + sx={{ cursor: 'pointer' }} + > + + - {dateStr} - {renderAction(item)} + {gw.displayName || gw.name} + {gw.gatewayType || 'External'} ); })}
- { - setPages(prev => ({ ...prev, [gwName]: newPage })); - }} - onRowsPerPageChange={(event) => { - setRowsPerPage(prev => ({ ...prev, [gwName]: parseInt(event.target.value, 10) })); - setPages(prev => ({ ...prev, [gwName]: 0 })); - }} - /> - - )} -
- ); - }; - - const totalApisCount = Object.values(discoveryResults).reduce( - (sum, res) => sum + (res.apis || []).length, - 0 - ); - - const hasErrors = Object.values(discoveryResults).some((r) => r.status === 'error'); - const isAnyPending = Object.values(discoveryResults).some((r) => r.status === 'pending'); - - return ( - -
- Discover APIs - - Discover and import APIs from federated gateways into WSO2 API Manager. - -
- - {error && ( - - {error} - - )} - {step === 1 && ( - - - Select Gateways - - {gateways.length === 0 ? ( - No external gateways available. - ) : ( - - {Object.entries(groupGatewaysByType(gateways)).map(([type, gwList]) => ( - - + + + - - - - )} - - {step === 2 && ( - - {discovering ? ( - - - Discovering APIs - - {selectedGateways.map((gw) => { - const result = discoveryResults[gw] || { - status: 'pending', - statusText: 'Queued...', - }; - - let displayStatusText = ''; - if (result.status === 'pending') { - displayStatusText = result.statusText; - } else if (result.status === 'success') { - displayStatusText = `${result.apis.length} APIs discovered`; - } else { - displayStatusText = result.error; - } - - const gwObj = gateways.find((g) => g.name === gw); - const gwType = gwObj ? (gwObj.gatewayType || 'External').toUpperCase() : ''; - - return ( - - - {result.status === 'pending' && } - {result.status === 'success' && ( - - )} - {result.status === 'error' && ( - - )} - - {gw}{gwType ? ` (${gwType})` : ''} - - - - {displayStatusText} - - - ); - })} - - ) : ( - <> - - - Discovered APIs ({totalApisCount}) - - - - - {totalApisCount === 0 && !hasErrors && !isAnyPending ? ( - - No new APIs discovered from the selected gateways. - - ) : ( - - {Object.entries(discoveryResults).map(([gwName, res]) => { - const isDone = res.status === 'success'; - const gwObj = gateways.find((g) => g.name === gwName); - const gwType = gwObj ? (gwObj.gatewayType || 'External').toUpperCase() : ''; - return ( - - - - Gateway: {gwName}{gwType ? ` (${gwType})` : ''} - - {isDone ? ( - - ) : ( - - )} - - {renderGatewayResults(gwName, res)} - - ); - })} - - )} - - )} - - )} + Discover APIs from {selectedGateways.length} gateway(s) + + + + + + + )} +
); }; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx new file mode 100644 index 00000000000..64c51d69392 --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx @@ -0,0 +1,1051 @@ +/* + * Copyright (c) 2026, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint-disable no-await-in-loop */ +import React, { useState, useEffect, useRef } from 'react'; +import { + Box, + Typography, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + CircularProgress, + Alert, + Chip, + TextField, + TablePagination, + InputAdornment, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Checkbox, + IconButton, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import InfoIcon from '@mui/icons-material/Info'; +import GetAppIcon from '@mui/icons-material/GetApp'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import EditIcon from '@mui/icons-material/Edit'; +import { styled } from '@mui/material/styles'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; +import AuthManager from 'AppData/AuthManager'; +import Utils from 'AppData/Utils'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { FormattedMessage } from 'react-intl'; +import APIMAlert from 'AppComponents/Shared/Alert'; + +const Root = styled('div')(({ theme }) => ({ + padding: theme.spacing(4), + '& .header': { + marginBottom: theme.spacing(4), + }, + '& .actions': { + marginTop: theme.spacing(2), + }, +})); + +// How often (ms) to poll the status endpoint while a task is PENDING +const POLL_INTERVAL_MS = 2000; +// Maximum time (ms) to wait before giving up on a task +const POLL_TIMEOUT_MS = 120000; + +/** + * Single poll attempt: checks GET /federated-apis/status/{taskId} once. + */ +const pollOnce = (taskId, basePath, token) => { + return fetch(`${basePath}/federated-apis/status/${taskId}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }).then((res) => { + if (!res.ok) { + throw new Error(`Status check failed for task ${taskId} (HTTP ${res.status})`); + } + return res.json(); + }).then((data) => { + if (data.status === 'COMPLETED') { + return data.result || []; + } + if (data.status === 'FAILED') { + throw new Error(data.error || `Task ${taskId} failed on the server.`); + } + return null; // still PENDING + }); +}; + +/** + * Polls GET /federated-apis/status/{taskId} until COMPLETED or FAILED. + */ +const pollTaskStatus = (taskId, basePath, token, startTime = Date.now()) => { + if (Date.now() - startTime >= POLL_TIMEOUT_MS) { + return Promise.reject( + new Error(`Discovery timed out after ${POLL_TIMEOUT_MS / 1000}s for task ${taskId}.`) + ); + } + return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + .then(() => pollOnce(taskId, basePath, token)) + .then((result) => { + if (result !== null) { + return result; // COMPLETED + } + return pollTaskStatus(taskId, basePath, token, startTime); + }); +}; + +const DiscoveryResults = (props) => { + const { history, location } = props; + const selectedGateways = (location.state && location.state.selectedGateways) || []; + + const { data: settings, isLoading } = usePublisherSettings(); + const [discoveryResults, setDiscoveryResults] = useState({}); + const [discovering, setDiscovering] = useState(false); + const [error, setError] = useState(null); + const [searchQueries, setSearchQueries] = useState({}); + const [pages, setPages] = useState({}); + const [rowsPerPage, setRowsPerPage] = useState({}); + const [importingStates, setImportingStates] = useState({}); + const [customizedApis, setCustomizedApis] = useState({}); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingApi, setEditingApi] = useState(null); + const [editedDisplayName, setEditedDisplayName] = useState(''); + const [editedDescription, setEditedDescription] = useState(''); + const [selectedApis, setSelectedApis] = useState({}); + const [importErrors, setImportErrors] = useState({}); + const discoveryTriggered = useRef(false); + + useEffect(() => { + if (!location.state || !location.state.selectedGateways || location.state.selectedGateways.length === 0) { + history.replace('/apis/discover'); + } + }, [location.state, history]); + + const handleDiscover = async () => { + setDiscovering(true); + setError(null); + setSearchQueries({}); + setPages({}); + setRowsPerPage({}); + setImportingStates({}); + setSelectedApis({}); + setImportErrors({}); + + const initialResults = {}; + selectedGateways.forEach((gw) => { + initialResults[gw] = { + status: 'pending', + statusText: 'Discovering...', + apis: [], + }; + }); + setDiscoveryResults(initialResults); + + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + + await Promise.all( + selectedGateways.map(async (gw) => { + try { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { + ...prev[gw], + statusText: 'Discovering...', + }, + })); + + const submitResponse = await fetch( + `${basePath}/federated-apis/discover?environment=${gw}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + if (!submitResponse.ok && submitResponse.status !== 202) { + throw new Error(`Failed to start discovery (HTTP ${submitResponse.status})`); + } + + const submitData = await submitResponse.json(); + const { taskId } = submitData; + + if (!taskId) { + throw new Error('No task ID returned'); + } + + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { + ...prev[gw], + statusText: 'Discovering...', + }, + })); + + const apiList = await pollTaskStatus(taskId, basePath, token); + + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'success', apis: apiList }, + })); + } catch (err) { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'error', error: err.message, apis: [] }, + })); + } + }) + ); + } catch (err) { + setError(err.message); + } finally { + setDiscovering(false); + } + }; + + useEffect(() => { + if (selectedGateways.length > 0 && !discoveryTriggered.current) { + discoveryTriggered.current = true; + handleDiscover(); + } + }, [selectedGateways]); + + const handleRetryGateway = async (gw) => { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { + status: 'pending', + statusText: 'Discovering...', + apis: [], + }, + })); + + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + + const submitResponse = await fetch( + `${basePath}/federated-apis/discover?environment=${gw}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + if (!submitResponse.ok && submitResponse.status !== 202) { + throw new Error(`Failed to start discovery (HTTP ${submitResponse.status})`); + } + + const submitData = await submitResponse.json(); + const { taskId } = submitData; + + if (!taskId) { + throw new Error('No task ID returned'); + } + + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { + ...prev[gw], + statusText: 'Discovering...', + }, + })); + + const apiList = await pollTaskStatus(taskId, basePath, token); + + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'success', apis: apiList }, + })); + } catch (err) { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'error', error: err.message, apis: [] }, + })); + } + }; + + const handleAction = async (item) => { + const apiId = item.id; + const { gatewayName } = item; + const isUpdate = item.status === 'UPDATE'; + + setImportingStates((prev) => ({ ...prev, [apiId]: 'importing' })); + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + const endpoint = isUpdate ? 'update' : 'import'; + + const customData = customizedApis[apiId]; + const payload = customData + ? JSON.stringify({ + id: apiId, + displayName: customData.displayName, + description: customData.description, + }) + : apiId; + + const url = `${basePath}/federated-apis/${endpoint}?environment=${gatewayName}`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify([payload]), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const backendMsg = errorData.message || `Failed to ${isUpdate ? 'update' : 'import'} API`; + throw new Error(backendMsg); + } + + setImportingStates((prev) => ({ ...prev, [apiId]: 'success' })); + setSelectedApis((prev) => { + const next = { ...prev }; + delete next[apiId]; + return next; + }); + setImportErrors((prev) => { + const next = { ...prev }; + delete next[apiId]; + return next; + }); + APIMAlert.success( + isUpdate + ? `API "${item.apiName}" updated successfully` + : `API "${item.apiName}" imported successfully` + ); + } catch (err) { + console.error(err); + setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); + setImportErrors((prev) => ({ ...prev, [apiId]: err.message })); + APIMAlert.error( + `Failed to ${isUpdate ? 'update' : 'import'} API "${item.apiName}": ${err.message}` + ); + } + }; + + const handleBulkImport = async (gwName, gwApis) => { + const toImport = gwApis.filter( + (item) => selectedApis[item.id] && importingStates[item.id] !== 'success' + ); + if (toImport.length === 0) return; + + for (const item of toImport) { + const apiId = item.id; + const isUpdate = item.status === 'UPDATE'; + setImportingStates((prev) => ({ ...prev, [apiId]: 'importing' })); + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + const endpoint = isUpdate ? 'update' : 'import'; + + const customData = customizedApis[apiId]; + const payload = customData + ? JSON.stringify({ + id: apiId, + displayName: customData.displayName, + description: customData.description, + }) + : apiId; + + const url = `${basePath}/federated-apis/${endpoint}?environment=${gwName}`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify([payload]), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const backendMsg = errorData.message || `Failed to ${isUpdate ? 'update' : 'import'} API`; + throw new Error(backendMsg); + } + + setImportingStates((prev) => ({ ...prev, [apiId]: 'success' })); + setSelectedApis((prev) => { + const next = { ...prev }; + delete next[apiId]; + return next; + }); + setImportErrors((prev) => { + const next = { ...prev }; + delete next[apiId]; + return next; + }); + APIMAlert.success( + isUpdate + ? `API "${item.apiName}" updated successfully` + : `API "${item.apiName}" imported successfully` + ); + } catch (err) { + console.error(err); + setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); + setImportErrors((prev) => ({ ...prev, [apiId]: err.message })); + APIMAlert.error( + `Failed to ${isUpdate ? 'update' : 'import'} API "${item.apiName}": ${err.message}` + ); + } + } + }; + + const renderAction = (item) => { + const apiId = item.id; + const isUpdate = item.status === 'UPDATE'; + const importState = importingStates[apiId]; + + if (importState === 'success') { + return ( + + ); + } + if (importState === 'importing') { + return ; + } + + let buttonColor = 'primary'; + let buttonText = 'Import'; + + if (importState === 'error') { + buttonColor = 'error'; + buttonText = 'Retry'; + } else if (isUpdate) { + buttonColor = 'success'; + buttonText = 'Update'; + } + + return ( + + {importState === 'error' && importErrors[apiId] && ( + + + + + + )} + + + + ); + }; + + const renderGatewayResults = (gwName, res) => { + if (res.status === 'pending') { + return ( + + + {res.statusText || 'Discovering...'} + + ); + } + + if (res.status === 'error') { + const rawError = res.error || 'Unknown error occurred during discovery.'; + + // Map rawError to a friendly explanation + let friendlyMessage = 'Discovery failed due to a server error or invalid gateway response. ' + + 'Please inspect the gateway logs.'; + const errLower = rawError.toLowerCase(); + + if ( + errLower.includes('aadsts') + || errLower.includes('unauthorized') + || errLower.includes('401') + || errLower.includes('invalid_client') + || errLower.includes('invalid client') + || errLower.includes('invalid_grant') + || errLower.includes('invalid grant') + || errLower.includes('forbidden') + || errLower.includes('403') + || errLower.includes('invalid key') + || errLower.includes('credentials') + || errLower.includes('api key') + || errLower.includes('service account') + ) { + friendlyMessage = 'Authentication failed. Please verify the credentials, API keys, ' + + 'or certificates configured for this gateway in the Admin portal.'; + } else if ( + errLower.includes('tenant') + || errLower.includes('project_id') + || errLower.includes('project') + || errLower.includes('not found') + || errLower.includes('404') + || errLower.includes('resource not found') + || errLower.includes('environment') + || errLower.includes('workspace') + || errLower.includes('organization') + ) { + friendlyMessage = 'Resource not found or configuration is invalid. ' + + 'Please verify the project ID, tenant, organization, or environment/workspace ' + + 'settings configured for this gateway in the Admin portal.'; + } else if ( + errLower.includes('timeout') + || errLower.includes('timed out') + || errLower.includes('connect') + || errLower.includes('connection refused') + || errLower.includes('dns') + || errLower.includes('resolve') + || errLower.includes('unreachable') + || errLower.includes('host') + || errLower.includes('network') + ) { + friendlyMessage = 'Network connection issue. The third-party gateway is unreachable. ' + + 'Please verify network connectivity, firewall rules, and the host URL config.'; + } else if ( + errLower.includes('429') + || errLower.includes('too many requests') + || errLower.includes('rate limit') + || errLower.includes('quota') + ) { + friendlyMessage = 'Rate limit exceeded. The third-party gateway rejected the requests ' + + 'because the quota or rate limit has been reached. Please try again later.'; + } + + return ( + + + + {friendlyMessage} + + + + + ); + } + + if (res.apis.length === 0) { + return ( + + + No new or updated APIs discovered from this gateway. + + + ); + } + + const query = (searchQueries[gwName] || '').toLowerCase(); + const filteredApis = res.apis.filter((item) => + (item.apiName && item.apiName.toLowerCase().includes(query)) || + (item.version && item.version.toLowerCase().includes(query)) || + (item.description && item.description.toLowerCase().includes(query)) || + (item.context && item.context.toLowerCase().includes(query)) + ); + + const page = pages[gwName] || 0; + const rpp = rowsPerPage[gwName] || 5; + const paginatedApis = filteredApis.slice(page * rpp, page * rpp + rpp); + + const checkableFilteredApis = filteredApis.filter( + (item) => importingStates[item.id] !== 'success' + ); + const selectedCheckableFilteredApis = checkableFilteredApis.filter( + (item) => selectedApis[item.id] + ); + const isAllSelected = checkableFilteredApis.length > 0 + && selectedCheckableFilteredApis.length === checkableFilteredApis.length; + const isSomeSelected = selectedCheckableFilteredApis.length > 0 + && selectedCheckableFilteredApis.length < checkableFilteredApis.length; + + return ( + + {filteredApis.length === 0 ? ( + + + No matching discovered APIs found. + + + ) : ( + <> + + + + + + { + const { checked } = e.target; + setSelectedApis((prev) => { + const next = { ...prev }; + checkableFilteredApis.forEach((item) => { + if (checked) { + next[item.id] = true; + } else { + delete next[item.id]; + } + }); + return next; + }); + }} + /> + + API Name + Version + Description + Context + API Type / Protocol + Status + Discovered At + Actions + + + + {paginatedApis.map((item) => { + const isNew = item.status === 'NEW'; + const date = item.discoveredAt + ? new Date(item.discoveredAt) + : new Date(); + const dateOnlyStr = date.toLocaleDateString(); + const dateTimeStr = date.toLocaleString(); + + return ( + + + { + const { checked } = e.target; + setSelectedApis((prev) => { + const next = { ...prev }; + if (checked) { + next[item.id] = true; + } else { + delete next[item.id]; + } + return next; + }); + }} + /> + + + {customizedApis[item.id]?.displayName || item.apiName} + + {item.version} + + {(() => { + const fullDesc = customizedApis[item.id]?.description + || item.description; + if (!fullDesc) return '-'; + if (fullDesc.length <= 50) return fullDesc; + const truncated = fullDesc.substring(0, 47) + '...'; + return ( + + + {truncated} + + + ); + })()} + + {item.context || '-'} + {item.apiType || 'HTTP'} + + + + + + {dateOnlyStr} + + + {renderAction(item)} + + ); + })} + +
+
+ { + setPages(prev => ({ ...prev, [gwName]: newPage })); + }} + onRowsPerPageChange={(event) => { + setRowsPerPage(prev => ({ + ...prev, + [gwName]: parseInt(event.target.value, 10), + })); + setPages(prev => ({ ...prev, [gwName]: 0 })); + }} + /> + + )} +
+ ); + }; + + if (isLoading || !selectedGateways || selectedGateways.length === 0) { + return null; + } + + const gateways = (settings?.environment || []).filter( + (env) => !env.provider.toLowerCase().includes('wso2') + ); + + const totalApisCount = Object.values(discoveryResults).reduce( + (sum, res) => sum + (res.apis || []).length, + 0 + ); + + const hasErrors = Object.values(discoveryResults).some((r) => r.status === 'error'); + const isAnyPending = Object.values(discoveryResults).some((r) => r.status === 'pending'); + + const handleBackToSelection = () => { + history.push({ + pathname: '/apis/discover', + state: { selectedGateways }, + }); + }; + + return ( + + + + +
+ Discover APIs + + Discover and import APIs from federated gateways into WSO2 API Manager. + +
+ + {error && ( + + {error} + + )} + + + {discovering ? ( + + + Discovering APIs + + {selectedGateways.map((gw) => { + const result = discoveryResults[gw] || { + status: 'pending', + statusText: 'Queued...', + }; + + let displayStatusText = ''; + if (result.status === 'pending') { + displayStatusText = result.statusText; + } else if (result.status === 'success') { + displayStatusText = `${result.apis.length} APIs discovered`; + } else { + displayStatusText = result.error; + } + + const gwObj = gateways.find((g) => g.name === gw); + const gwType = gwObj ? (gwObj.gatewayType || 'External').toUpperCase() : ''; + + return ( + + + {result.status === 'pending' && } + {result.status === 'success' && ( + + )} + {result.status === 'error' && ( + + )} + + {gw}{gwType ? ` (${gwType})` : ''} + + + + {displayStatusText} + + + ); + })} + + ) : ( + <> + + + Discovered APIs ({totalApisCount}) + + + + {totalApisCount === 0 && !hasErrors && !isAnyPending ? ( + + No new APIs discovered from the selected gateways. + + ) : ( + + {Object.entries(discoveryResults).map(([gwName, res]) => { + const isDone = res.status === 'success'; + const gwObj = gateways.find((g) => g.name === gwName); + const gwType = gwObj ? (gwObj.gatewayType || 'External').toUpperCase() : ''; + return ( + + + + + Gateway: {gwName}{gwType ? ` (${gwType})` : ''} + + {isDone ? ( + + ) : ( + + )} + + {isDone && res.apis && res.apis.length > 0 && ( + + {(() => { + const selectedCountForGw = res.apis.filter( + (item) => selectedApis[item.id] + && importingStates[item.id] !== 'success' + ).length; + if (selectedCountForGw > 0) { + const isGwBulkImporting = res.apis.some( + (item) => selectedApis[item.id] + && importingStates[item.id] === 'importing' + ); + return ( + + ); + } + return null; + })()} + { + setSearchQueries((prev) => ({ + ...prev, + [gwName]: e.target.value, + })); + setPages((prev) => ({ ...prev, [gwName]: 0 })); + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ width: 250 }} + /> + + )} + + {renderGatewayResults(gwName, res)} + + ); + })} + + )} + + )} + + setIsEditDialogOpen(false)} + fullWidth + maxWidth='sm' + > + Edit API Details + + + setEditedDisplayName(e.target.value)} + /> + setEditedDescription(e.target.value)} + /> + + + + + + + +
+ ); +}; + +export default DiscoveryResults; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/components/TopMenu.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/components/TopMenu.jsx index d3354bdcaa7..47462ecc5a5 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/components/TopMenu.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/components/TopMenu.jsx @@ -31,6 +31,9 @@ import { FormattedMessage } from 'react-intl'; import ButtonGroup from '@mui/material/ButtonGroup'; import VerticalDivider from 'AppComponents/Shared/VerticalDivider'; import { isRestricted } from 'AppData/AuthManager'; +import ExploreIcon from '@mui/icons-material/Explore'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; +import Tooltip from '@mui/material/Tooltip'; const PREFIX = 'TopMenu'; @@ -134,6 +137,8 @@ function TopMenu(props) { const { data, setListType, count, isAPIProduct, isMCPServer, listType, showToggle, query, } = props; + const { data: settings } = usePublisherSettings(); + const isFederatedAPIDiscoveryEnabled = settings && settings.isFederatedAPIDiscoveryEnabled; const isAPIAccessRestricted = () => { return isRestricted(['apim:api_create', 'apim:api_manage']); @@ -233,20 +238,51 @@ function TopMenu(props) { )} {!query && !isAPIProduct && !isMCPServer && ( - + <> + {isFederatedAPIDiscoveryEnabled && ( + + } + > + + + )} + + )} {showToggle && ( From 70aacc6b67e007727e0440cb6e268bc177550e16 Mon Sep 17 00:00:00 2001 From: DinithEdirisinghe Date: Sat, 4 Jul 2026 14:04:04 +0530 Subject: [PATCH 08/12] feat: implement federated API discovery module and integration in the publisher portal --- .../main/webapp/site/public/locales/en.json | 1 + .../components/Apis/Discover/DiscoverAPIs.jsx | 201 +++++--- .../Apis/Discover/DiscoveryResults.jsx | 435 ++++++++++-------- .../Landing/Menus/DiscoverAPIsCard.jsx | 89 ++++ .../components/Apis/Listing/Landing/index.jsx | 14 +- .../PublisherLanding/ApisSection.jsx | 38 +- 6 files changed, 498 insertions(+), 280 deletions(-) create mode 100644 portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/Menus/DiscoverAPIsCard.jsx diff --git a/portals/publisher/src/main/webapp/site/public/locales/en.json b/portals/publisher/src/main/webapp/site/public/locales/en.json index 28443bd0c7f..d5897602ff7 100644 --- a/portals/publisher/src/main/webapp/site/public/locales/en.json +++ b/portals/publisher/src/main/webapp/site/public/locales/en.json @@ -2402,6 +2402,7 @@ "Publisher.Landing.create.mcp.button": "Create MCP Server", "Publisher.Landing.description": "Let's get started!", "Publisher.Landing.discover.apis.button": "Discover APIs", + "Publisher.Landing.discover.apis.card.title": "Discover APIs", "Publisher.Landing.error.title": "Error Loading Data", "Publisher.Landing.mcpServers.section.title": "MCP Servers", "Publisher.Landing.no.api.products.message.line1": "No API Products found.", diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx index 642dc91694c..fbc7262ffe5 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -21,17 +21,13 @@ import { Box, Typography, Button, - Checkbox, + Radio, Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, CircularProgress, Link as MuiLink, Tooltip, + Grid, + useTheme, } from '@mui/material'; import { styled } from '@mui/material/styles'; import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; @@ -51,13 +47,21 @@ const Root = styled('div')(({ theme }) => ({ })); const DiscoverAPIs = (props) => { + const theme = useTheme(); const { history, location } = props; const { data: settings, isLoading } = usePublisherSettings(); const handleBackClick = useBackNavigation('/apis'); - // Initialize selectedGateways from history location state if navigating back - const initialSelected = (location.state && location.state.selectedGateways) || []; - const [selectedGateways, setSelectedGateways] = useState(initialSelected); + let initialSelected = null; + if (location.state && location.state.selectedGateways) { + if (Array.isArray(location.state.selectedGateways)) { + const [firstGateway] = location.state.selectedGateways; + initialSelected = firstGateway; + } else { + initialSelected = location.state.selectedGateways; + } + } + const [selectedGateway, setSelectedGateway] = useState(initialSelected); if (isLoading) { return ; @@ -67,29 +71,36 @@ const DiscoverAPIs = (props) => { (env) => !env.provider.toLowerCase().includes('wso2') ); - const handleToggleGateway = (gatewayName) => { - setSelectedGateways((prev) => - prev.includes(gatewayName) - ? prev.filter((g) => g !== gatewayName) - : [...prev, gatewayName] - ); + const handleSelectGateway = (gatewayName) => { + setSelectedGateway(gatewayName); }; - const handleSelectAllGateways = (event) => { - if (event.target.checked) { - setSelectedGateways(gateways.map((gw) => gw.name)); - } else { - setSelectedGateways([]); + const getGatewayChipColor = (gwType) => { + const type = (gwType || '').toUpperCase(); + if (type.includes('AWS')) { + return '#FF9900'; } + if (type.includes('KONG')) { + return '#1A5C96'; + } + if (type.includes('APIGEE')) { + return '#F27318'; + } + if (type.includes('AZURE')) { + return '#0078D4'; + } + return '#4d4d4d'; }; const handleDiscover = () => { history.push({ pathname: '/apis/discover/apis', - state: { selectedGateways }, + state: { selectedGateways: [selectedGateway] }, }); }; + const selectedGatewayObj = gateways.find((gw) => gw.name === selectedGateway); + return ( @@ -145,68 +156,110 @@ const DiscoverAPIs = (props) => { ) : ( <> - Select Gateways + Select a Gateway - - - - - - 0 - && selectedGateways.length < gateways.length - } - checked={ - gateways.length > 0 - && selectedGateways.length === gateways.length - } - onChange={handleSelectAllGateways} - color='primary' - /> - - Gateway Name - Gateway Type - - - - {gateways.map((gw) => { - const isSelected = selectedGateways.includes(gw.name); - return ( - handleToggleGateway(gw.name)} - role='checkbox' - aria-checked={isSelected} - selected={isSelected} - sx={{ cursor: 'pointer' }} + + {gateways.map((gw) => { + const isSelected = selectedGateway === gw.name; + return ( + + handleSelectGateway(gw.name)} + variant='outlined' + sx={{ + p: 3, + display: 'flex', + flexDirection: 'column', + position: 'relative', + cursor: 'pointer', + borderRadius: 3, + border: isSelected + ? `2px solid ${theme.palette.primary.main}` + : '1px solid #e0e0e0', + backgroundColor: isSelected + ? 'rgba(25, 118, 210, 0.04)' + : '#ffffff', + transition: 'all 0.2s ease-in-out', + boxShadow: isSelected ? 2 : 0, + '&:hover': { + boxShadow: 3, + borderColor: isSelected + ? theme.palette.primary.main + : '#999999', + transform: 'translateY(-2px)', + }, + }} + > + - - - - {gw.displayName || gw.name} - {gw.gatewayType || 'External'} - - ); - })} - -
-
+ + + {gw.displayName || gw.name} + + + {gw.gatewayType || 'External'} + + + handleSelectGateway(gw.name)} + value={gw.name} + name='gateway-selection' + color='primary' + sx={{ p: 0 }} + /> +
+
+ + ); + })} +
- + diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx index 64c51d69392..02c7ba56918 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx @@ -36,18 +36,16 @@ import { TablePagination, InputAdornment, Tooltip, - Dialog, - DialogTitle, - DialogContent, - DialogActions, Checkbox, IconButton, + Select, + MenuItem, + FormControl, } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import InfoIcon from '@mui/icons-material/Info'; import GetAppIcon from '@mui/icons-material/GetApp'; import RefreshIcon from '@mui/icons-material/Refresh'; -import EditIcon from '@mui/icons-material/Edit'; import { styled } from '@mui/material/styles'; import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; import AuthManager from 'AppData/AuthManager'; @@ -115,6 +113,72 @@ const pollTaskStatus = (taskId, basePath, token, startTime = Date.now()) => { }); }; +/** + * Map rawError to a friendly explanation + */ +const getFriendlyErrorMessage = (rawError) => { + if (!rawError) { + return 'Discovery failed due to a server error or invalid gateway response. ' + + 'Please inspect the gateway logs.'; + } + const errLower = rawError.toLowerCase(); + if ( + errLower.includes('aadsts') + || errLower.includes('unauthorized') + || errLower.includes('401') + || errLower.includes('invalid_client') + || errLower.includes('invalid client') + || errLower.includes('invalid_grant') + || errLower.includes('invalid grant') + || errLower.includes('forbidden') + || errLower.includes('403') + || errLower.includes('invalid key') + || errLower.includes('credentials') + || errLower.includes('api key') + || errLower.includes('service account') + ) { + return 'Authentication failed. Please verify the credentials, API keys, ' + + 'or certificates configured for this gateway in the Admin portal.'; + } else if ( + errLower.includes('tenant') + || errLower.includes('project_id') + || errLower.includes('project') + || errLower.includes('not found') + || errLower.includes('404') + || errLower.includes('resource not found') + || errLower.includes('environment') + || errLower.includes('workspace') + || errLower.includes('organization') + ) { + return 'Resource not found or configuration is invalid. Please verify the project ID, ' + + 'tenant, organization, or environment/workspace settings configured ' + + 'for this gateway in the Admin portal.'; + } else if ( + errLower.includes('timeout') + || errLower.includes('timed out') + || errLower.includes('connect') + || errLower.includes('connection refused') + || errLower.includes('dns') + || errLower.includes('resolve') + || errLower.includes('unreachable') + || errLower.includes('host') + || errLower.includes('network') + ) { + return 'Network connection issue. The third-party gateway is unreachable. ' + + 'Please verify network connectivity, firewall rules, and the host URL config.'; + } else if ( + errLower.includes('429') + || errLower.includes('too many requests') + || errLower.includes('rate limit') + || errLower.includes('quota') + ) { + return 'Rate limit exceeded. The third-party gateway rejected the requests ' + + 'because the quota or rate limit has been reached. Please try again later.'; + } + return 'Discovery failed due to a server error or invalid gateway response. ' + + 'Please inspect the gateway logs.'; +}; + const DiscoveryResults = (props) => { const { history, location } = props; const selectedGateways = (location.state && location.state.selectedGateways) || []; @@ -124,18 +188,49 @@ const DiscoveryResults = (props) => { const [discovering, setDiscovering] = useState(false); const [error, setError] = useState(null); const [searchQueries, setSearchQueries] = useState({}); + const [statusFilters, setStatusFilters] = useState({}); const [pages, setPages] = useState({}); const [rowsPerPage, setRowsPerPage] = useState({}); const [importingStates, setImportingStates] = useState({}); - const [customizedApis, setCustomizedApis] = useState({}); - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [editingApi, setEditingApi] = useState(null); - const [editedDisplayName, setEditedDisplayName] = useState(''); - const [editedDescription, setEditedDescription] = useState(''); + const [selectedApis, setSelectedApis] = useState({}); const [importErrors, setImportErrors] = useState({}); + const [lastDiscoveredAt, setLastDiscoveredAt] = useState(null); const discoveryTriggered = useRef(false); + // Load cached results from DB on mount + const loadCachedResults = async (gw) => { + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + const response = await fetch( + `${basePath}/federated-apis/cached?environment=${gw}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + if (response.ok) { + const data = await response.json(); + if (data.lastDiscoveredAt) { + setLastDiscoveredAt(data.lastDiscoveredAt); + } + if (data.result && data.result.length > 0) { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'success', apis: data.result }, + })); + return true; + } + } + } catch (err) { + console.warn('Failed to load cached results:', err); + } + return false; + }; + useEffect(() => { if (!location.state || !location.state.selectedGateways || location.state.selectedGateways.length === 0) { history.replace('/apis/discover'); @@ -146,6 +241,7 @@ const DiscoveryResults = (props) => { setDiscovering(true); setError(null); setSearchQueries({}); + setStatusFilters({}); setPages({}); setRowsPerPage({}); setImportingStates({}); @@ -209,6 +305,13 @@ const DiscoveryResults = (props) => { const apiList = await pollTaskStatus(taskId, basePath, token); + // Update lastDiscoveredAt from the result + if (apiList.length > 0 && apiList[0].discoveredAt) { + setLastDiscoveredAt(apiList[0].discoveredAt); + } else { + setLastDiscoveredAt(new Date().toISOString()); + } + setDiscoveryResults((prev) => ({ ...prev, [gw]: { status: 'success', apis: apiList }, @@ -229,10 +332,19 @@ const DiscoveryResults = (props) => { }; useEffect(() => { - if (selectedGateways.length > 0 && !discoveryTriggered.current) { - discoveryTriggered.current = true; - handleDiscover(); - } + const initDiscovery = async () => { + if (selectedGateways.length > 0 && !discoveryTriggered.current) { + discoveryTriggered.current = true; + // Try loading from cache first + const gw = selectedGateways[0]; + const hasCached = await loadCachedResults(gw); + if (!hasCached) { + // No cache - trigger fresh discovery + handleDiscover(); + } + } + }; + initDiscovery(); }, [selectedGateways]); const handleRetryGateway = async (gw) => { @@ -281,6 +393,13 @@ const DiscoveryResults = (props) => { const apiList = await pollTaskStatus(taskId, basePath, token); + // Update lastDiscoveredAt from the result + if (apiList.length > 0 && apiList[0].discoveredAt) { + setLastDiscoveredAt(apiList[0].discoveredAt); + } else { + setLastDiscoveredAt(new Date().toISOString()); + } + setDiscoveryResults((prev) => ({ ...prev, [gw]: { status: 'success', apis: apiList }, @@ -304,14 +423,7 @@ const DiscoveryResults = (props) => { const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); const endpoint = isUpdate ? 'update' : 'import'; - const customData = customizedApis[apiId]; - const payload = customData - ? JSON.stringify({ - id: apiId, - displayName: customData.displayName, - description: customData.description, - }) - : apiId; + const payload = apiId; const url = `${basePath}/federated-apis/${endpoint}?environment=${gatewayName}`; const response = await fetch(url, { @@ -348,10 +460,11 @@ const DiscoveryResults = (props) => { ); } catch (err) { console.error(err); + const friendly = getFriendlyErrorMessage(err.message); setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); - setImportErrors((prev) => ({ ...prev, [apiId]: err.message })); + setImportErrors((prev) => ({ ...prev, [apiId]: friendly })); APIMAlert.error( - `Failed to ${isUpdate ? 'update' : 'import'} API "${item.apiName}": ${err.message}` + `Failed to ${isUpdate ? 'update' : 'import'} API "${item.apiName}": ${friendly}` ); } }; @@ -371,14 +484,7 @@ const DiscoveryResults = (props) => { const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); const endpoint = isUpdate ? 'update' : 'import'; - const customData = customizedApis[apiId]; - const payload = customData - ? JSON.stringify({ - id: apiId, - displayName: customData.displayName, - description: customData.description, - }) - : apiId; + const payload = apiId; const url = `${basePath}/federated-apis/${endpoint}?environment=${gwName}`; const response = await fetch(url, { @@ -415,10 +521,11 @@ const DiscoveryResults = (props) => { ); } catch (err) { console.error(err); + const friendly = getFriendlyErrorMessage(err.message); setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); - setImportErrors((prev) => ({ ...prev, [apiId]: err.message })); + setImportErrors((prev) => ({ ...prev, [apiId]: friendly })); APIMAlert.error( - `Failed to ${isUpdate ? 'update' : 'import'} API "${item.apiName}": ${err.message}` + `Failed to ${isUpdate ? 'update' : 'import'} API "${item.apiName}": ${friendly}` ); } } @@ -463,24 +570,6 @@ const DiscoveryResults = (props) => { )} - {totalApisCount === 0 && !hasErrors && !isAnyPending ? ( - No new APIs discovered from the selected gateways. + + {lastDiscoveredAt + ? 'No new or updated APIs discovered from this gateway.' + : 'No previous discovery data. Click Refresh to discover APIs.'} + + {!lastDiscoveredAt && ( + + )} ) : ( @@ -961,6 +1048,22 @@ const DiscoveryResults = (props) => { } return null; })()} + + + { )} - setIsEditDialogOpen(false)} - fullWidth - maxWidth='sm' - > - Edit API Details - - - setEditedDisplayName(e.target.value)} - /> - setEditedDescription(e.target.value)} - /> - - - - - - - + ); }; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/Menus/DiscoverAPIsCard.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/Menus/DiscoverAPIsCard.jsx new file mode 100644 index 00000000000..9afc85e1877 --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/Menus/DiscoverAPIsCard.jsx @@ -0,0 +1,89 @@ +/* eslint-disable */ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { Box, Button, Typography, useTheme } from '@mui/material'; +import Paper from '@mui/material/Paper'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { app } from 'Settings'; +import { isRestricted } from 'AppData/AuthManager'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import ExploreIcon from '@mui/icons-material/Explore'; + +const DiscoverAPIsCard = () => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isMediumScreen = useMediaQuery(theme.breakpoints.between('md', 'lg')); + + return ( + + + Discover APIs + + + + + + + + + + + + ); +}; + +export default DiscoverAPIsCard; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/index.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/index.jsx index 1121b99fa1c..78a24d5408d 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/index.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/index.jsx @@ -31,6 +31,7 @@ import SoapAPIMenu from 'AppComponents/Apis/Listing/Landing/Menus/SoapAPIMenu'; import GraphqlAPIMenu from 'AppComponents/Apis/Listing/Landing/Menus/GraphqlAPIMenu'; import StreamingAPIMenu from 'AppComponents/Apis/Listing/Landing/Menus/StreamingAPIMenu'; import DesignAssistantMenu from './Menus/DesignAssistantMenu'; +import DiscoverAPIsCard from './Menus/DiscoverAPIsCard'; import AIAPIMenu from './Menus/AIAPIMenu'; const PREFIX = 'APILanding'; @@ -49,6 +50,7 @@ const APILanding = () => { const theme = useTheme(); const isXsOrBelow = useMediaQuery(theme.breakpoints.down('xs')); const { data: settings } = usePublisherSettings(); + const isFederatedAPIDiscoveryEnabled = settings && settings.isFederatedAPIDiscoveryEnabled; const [gateway, setGatewayType] = useState(true); const [pageMode, setPageMode] = useState('default'); const location = useLocation(); @@ -203,17 +205,25 @@ const APILanding = () => { } - {settings.designAssistantEnabled && ( + {(settings.designAssistantEnabled || isFederatedAPIDiscoveryEnabled) && ( - + {settings.designAssistantEnabled && ( + + )} + {isFederatedAPIDiscoveryEnabled && ( + + )} )} diff --git a/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx b/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx index a63b1e20f92..b22d2bf74c8 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/PublisherLanding/ApisSection.jsx @@ -17,7 +17,7 @@ */ import React from 'react'; -import { Box, Typography, Button } from '@mui/material'; +import { Box, Typography, Button, Tooltip } from '@mui/material'; import { styled, useTheme } from '@mui/material/styles'; import { Link } from 'react-router-dom'; import AddIcon from '@mui/icons-material/Add'; @@ -120,19 +120,31 @@ const ApisSection = ({ data, totalCount, onDelete }) => {
{isFederatedAPIDiscoveryEnabled && ( - + + )}