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..d5897602ff7 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", @@ -2399,6 +2401,8 @@ "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.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/Apis.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Apis.jsx index cbf939dccf5..52fd6882a46 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,9 +21,12 @@ 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'; +import DiscoverAPIs from './Discover/DiscoverAPIs'; +import DiscoveryResults from './Discover/DiscoveryResults'; /* if needs to pre fetch use 'webpackPrefetch: true' */ @@ -58,13 +61,13 @@ const Apis = () => { } /> { return ; }} @@ -72,7 +75,7 @@ const Apis = () => { ( @@ -82,9 +85,30 @@ 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 new file mode 100644 index 00000000000..eb0c1296656 --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoverAPIs.jsx @@ -0,0 +1,297 @@ +/* + * 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 PropTypes from 'prop-types'; +import { + Box, + Typography, + Button, + Radio, + Paper, + CircularProgress, + Link as MuiLink, + Tooltip, + Grid, + useTheme, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; +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), + }, + '& .actions': { + marginTop: theme.spacing(2), + }, +})); + +const DiscoverAPIs = (props) => { + const theme = useTheme(); + const { history, location } = props; + const { data: settings, isLoading } = usePublisherSettings(); + const handleBackClick = useBackNavigation('/apis'); + + let initialSelected = null; + if (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 ; + } + + const gateways = (settings?.environment || []).filter( + (env) => !env.provider.toLowerCase().includes('wso2') + ); + + const handleSelectGateway = (gatewayName) => { + setSelectedGateway(gatewayName); + }; + + 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: [selectedGateway] }, + }); + }; + + const selectedGatewayObj = gateways.find((gw) => gw.name === selectedGateway); + + return ( + + + + +
+ Discover APIs + + Discover and import APIs from federated gateways into WSO2 API Manager. + +
+ + + {gateways.length === 0 ? ( + + + No available gateways + + + There are no external/federated gateways configured in the system. + {' '} + You can add them from the Admin portal. + + + Learn more + + + ) : ( + <> + + Select a Gateway + + + {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'} + + + handleSelectGateway(gw.name)} + value={gw.name} + name='gateway-selection' + color='primary' + sx={{ p: 0 }} + /> + + + + ); + })} + + +
+ + + + + + +
+ + )} +
+
+ ); +}; + +DiscoverAPIs.propTypes = { + history: PropTypes.shape({ + push: PropTypes.func, + }).isRequired, + location: PropTypes.shape({ + state: PropTypes.shape({ + selectedGateways: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + }), + }).isRequired, +}; + +export default DiscoverAPIs; 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..919413fed10 --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Discover/DiscoveryResults.jsx @@ -0,0 +1,1015 @@ +/* + * 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 PropTypes from 'prop-types'; +import { + Box, + Typography, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + CircularProgress, + Alert, + Chip, + TextField, + TablePagination, + InputAdornment, + Tooltip, + 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 { 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); + }); +}; + +/** + * 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.'; +}; + +/** + * Helper to import/update a single API from a federated gateway. + */ +const importSingleApi = async (item, gwName, setImportingStates, setSelectedApis, setImportErrors) => { + const apiId = item.id; + const isUpdate = item.status === 'UPDATE'; + const actionLabel = isUpdate ? 'update' : 'import'; + setImportingStates((prev) => ({ ...prev, [apiId]: 'importing' })); + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + const url = `${basePath}/federated-apis/${actionLabel}?environment=${gwName}`; + 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) { + const errorData = await response.json().catch(() => ({})); + const backendMsg = errorData.message || `Failed to ${actionLabel} 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); + const friendly = getFriendlyErrorMessage(err.message); + setImportingStates((prev) => ({ ...prev, [apiId]: 'error' })); + setImportErrors((prev) => ({ ...prev, [apiId]: friendly })); + APIMAlert.error( + `Failed to ${actionLabel} API "${item.apiName}": ${friendly}` + ); + } +}; + +const DiscoveryResults = (props) => { + const { history, location } = props; + const selectedGateways = 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 [statusFilters, setStatusFilters] = useState({}); + const [pages, setPages] = useState({}); + const [rowsPerPage, setRowsPerPage] = useState({}); + const [importingStates, setImportingStates] = 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?.selectedGateways?.length) { + history.replace('/apis/discover'); + } + }, [location.state, history]); + + const discoverGateway = async (gw, token, basePath) => { + 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'); + } + const apiList = await pollTaskStatus(taskId, basePath, token); + if (apiList.length > 0 && apiList[0].discoveredAt) { + setLastDiscoveredAt(apiList[0].discoveredAt); + } else { + setLastDiscoveredAt(new Date().toISOString()); + } + return apiList; + }; + + const handleDiscover = async () => { + setDiscovering(true); + setError(null); + setSearchQueries({}); + setStatusFilters({}); + 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 apiList = await discoverGateway(gw, token, basePath); + 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(() => { + 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) => { + try { + const token = AuthManager.getUser().getPartialToken(); + const basePath = Utils.getSwaggerURL().replace('/swagger.yaml', ''); + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'pending', statusText: 'Discovering...', apis: [] }, + })); + const apiList = await discoverGateway(gw, token, basePath); + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'success', apis: apiList }, + })); + } catch (err) { + setDiscoveryResults((prev) => ({ + ...prev, + [gw]: { status: 'error', error: err.message, apis: [] }, + })); + } + }; + + const handleAction = async (item) => { + await importSingleApi(item, item.gatewayName, setImportingStates, setSelectedApis, setImportErrors); + }; + + 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) { + await importSingleApi(item, gwName, setImportingStates, setSelectedApis, setImportErrors); + } + }; + + 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.'; + const friendlyMessage = getFriendlyErrorMessage(rawError); + + return ( + + + + {friendlyMessage} + + + + + ); + } + + if (res.apis.length === 0) { + return ( + + + No new or updated APIs discovered from this gateway. + + + ); + } + + const query = (searchQueries[gwName] || '').toLowerCase(); + const statusFilter = statusFilters[gwName] || 'ALL'; + const filteredApis = res.apis.filter((item) => { + const matchesQuery = + item.apiName?.toLowerCase().includes(query) || + item.version?.toLowerCase().includes(query) || + item.description?.toLowerCase().includes(query) || + item.context?.toLowerCase().includes(query); + + const matchesStatus = + statusFilter === 'ALL' || + (statusFilter === 'NEW' && item.status === 'NEW') || + (statusFilter === 'UPDATE' && item.status === 'UPDATE'); + + return matchesQuery && matchesStatus; + }); + + 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; + + const handleSelectAll = (e) => { + const { checked } = e.target; + const next = { ...selectedApis }; + checkableFilteredApis.forEach((item) => { + if (checked) { + next[item.id] = true; + } else { + delete next[item.id]; + } + }); + setSelectedApis(next); + }; + + const handleRowCheck = (e, item) => { + const { checked } = e.target; + const next = { ...selectedApis }; + if (checked) { + next[item.id] = true; + } else { + delete next[item.id]; + } + setSelectedApis(next); + }; + + const renderDescription = (desc) => { + if (!desc) return '-'; + if (desc.length <= 50) return desc; + const truncated = desc.substring(0, 47) + '...'; + return ( + + + {truncated} + + + ); + }; + + const renderProtocol = (type) => { + const typeUpper = (type || 'HTTP').toUpperCase(); + if (typeUpper === 'HTTP') return 'REST'; + if (typeUpper === 'WS') return 'WebSocket'; + if (typeUpper === 'WEBSOCKET') return 'WebSocket'; + return typeUpper; + }; + + const renderRow = (item) => { + const isNew = item.status === 'NEW'; + return ( + + + handleRowCheck(e, item)} + /> + + + {item.apiName} + + {item.version} + + {renderDescription(item.description)} + + {item.context || '-'} + + {renderProtocol(item.apiType)} + + + + + {renderAction(item)} + + ); + }; + + return ( + + {filteredApis.length === 0 ? ( + + + No matching discovered APIs found. + + + ) : ( + <> + + + + + + + + API Name + Version + Description + Context + Protocol + Status + Actions + + + + {paginatedApis.map(renderRow)} + +
+
+ { + setPages(prev => ({ ...prev, [gwName]: newPage })); + }} + onRowsPerPageChange={(event) => { + setRowsPerPage(prev => ({ + ...prev, + [gwName]: Number.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 { + const friendly = getFriendlyErrorMessage(result.error); + displayStatusText = friendly; + } + + 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} + + + ); + })} + + ) : ( + <> + + + {lastDiscoveredAt && ( + + Last Discovered: {new Date(lastDiscoveredAt).toLocaleString()} + + )} + + + + + {totalApisCount === 0 && !hasErrors && !isAnyPending ? ( + + + {lastDiscoveredAt + ? 'No new or updated APIs discovered from this gateway.' + : 'No previous discovery data. Click Refresh to discover APIs.'} + + {!lastDiscoveredAt && ( + + )} + + ) : ( + + {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)} + + ); + })} + + )} + + )} + + +
+ ); +}; + +DiscoveryResults.propTypes = { + history: PropTypes.shape({ + push: PropTypes.func, + replace: PropTypes.func, + }).isRequired, + location: PropTypes.shape({ + state: PropTypes.shape({ + selectedGateways: PropTypes.arrayOf(PropTypes.string), + }), + }).isRequired, +}; + +export default DiscoveryResults; 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..27001a3e074 --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/Landing/Menus/DiscoverAPIsCard.jsx @@ -0,0 +1,114 @@ +/* eslint-disable no-unused-vars, react/prop-types */ +/* + * 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')); + + let maxWidth = 500; + if (isSmallScreen) { + maxWidth = 250; + } else if (isMediumScreen) { + maxWidth = 400; + } + + let height = 140; + if (isMediumScreen) { + height = 150; + } + + 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..96c60721db0 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?.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/Apis/Listing/components/TopMenu.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Listing/components/TopMenu.jsx index d3354bdcaa7..048138b68a4 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?.isFederatedAPIDiscoveryEnabled; const isAPIAccessRestricted = () => { return isRestricted(['apim:api_create', 'apim:api_manage']); @@ -233,20 +238,51 @@ function TopMenu(props) { )} {!query && !isAPIProduct && !isMCPServer && ( - + <> + {isFederatedAPIDiscoveryEnabled && ( + + } + > + + + )} + + )} {showToggle && ( 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..b19d156a8f6 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,15 +17,17 @@ */ 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'; 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'; +import { usePublisherSettings } from 'AppComponents/Shared/AppContext'; import DataTable from './DataTable'; const PREFIX = 'ApisSection'; @@ -105,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?.isFederatedAPIDiscoveryEnabled; return (
@@ -114,16 +118,45 @@ const ApisSection = ({ data, totalCount, onDelete }) => {
- + + {isFederatedAPIDiscoveryEnabled && ( + + } + > + + + )} + + {data.length === 0 ? ( { + const { data: settings, isLoading } = usePublisherSettings(); + + // Show loading state while settings are being fetched + if (isLoading) { + return ; + } + + const isFederatedAPIDiscoveryEnabled = settings?.isFederatedAPIDiscoveryEnabled; + + if (!isFederatedAPIDiscoveryEnabled) { + const resourceNotFoundMessage = { + title: 'Federated API Discovery Disabled', + body: 'Federated API Discovery is not enabled in this environment.', + }; + return ; + } + + return children; +}; + +FederatedAPIDiscoveryRouteGuard.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default FederatedAPIDiscoveryRouteGuard;