diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx index d36dc8adc6..b4512cf5a6 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx @@ -135,18 +135,16 @@ const DeployFeature: FC = ({ port }) => { */ const handleDeploy = ( target: Environment, - gatewayId: string, - endpointUrl: string, + gateways: { gatewayId: string; endpointUrl?: string }[], from?: Environment, buildId?: string ) => { - if (!client) return; + if (!client || gateways.length === 0) return; void runAction( () => client.deploy({ environment: target.name, - gatewayId, - endpointUrl, + gateways, fromEnvironment: from?.name, buildId, }), @@ -155,28 +153,28 @@ const DeployFeature: FC = ({ port }) => { ); }; - const handleStop = (environment: Environment, gatewayId: string) => { - const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId); - if (!client || !gateway?.deploymentId) return; + /** + * Deleting a build is how a slot is freed once the API is at its limit and + * deploying is refused for it. The platform is the authority on whether a build + * can go — a gateway may have claimed it since the page last loaded — so a + * refusal surfaces as it comes back rather than being predicted here. + */ + const handleDeleteBuild = (buildId: string) => { + if (!client) return; void runAction( - () => client.undeploy(environment.name, gatewayId, gateway.deploymentId!), - `Stopping ${gateway.name}.`, - `Unable to stop ${gateway.name}.` + () => client.deleteBuild(buildId), + `Deleted build ${buildId}.`, + `Unable to delete build ${buildId}.` ); }; - /** - * Puts a suspended deployment back on its gateway. The deployment is immutable, - * so this restores exactly what was running — same build, same endpoint — and - * builds nothing, which is what separates it from a retry. - */ - const handleRedeploy = (environment: Environment, gatewayId: string) => { + const handleStop = (environment: Environment, gatewayId: string) => { const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId); if (!client || !gateway?.deploymentId) return; void runAction( - () => client.redeploy(environment.name, gatewayId, gateway.deploymentId!), - `Redeploying ${gateway.name}.`, - `Unable to redeploy ${gateway.name}.` + () => client.undeploy(environment.name, gatewayId, gateway.deploymentId!), + `Stopping ${gateway.name}.`, + `Unable to stop ${gateway.name}.` ); }; @@ -192,15 +190,17 @@ const DeployFeature: FC = ({ port }) => { const index = environments.findIndex((candidate) => candidate.name === environment.name); void runAction( () => + // Only this gateway: the retry ships the build its peers are already + // running, so the environment stays on one build and the backend does not + // require them to be redeployed alongside it. client.deploy({ environment: environment.name, - gatewayId, - endpointUrl: gateway.endpointUrl, + gateways: [{ gatewayId, endpointUrl: gateway.endpointUrl }], buildId: gateway.buildId, fromEnvironment: index > 0 ? environments[index - 1]?.name : undefined, }), - `Redeploying ${gateway.name}.`, - `Unable to redeploy ${gateway.name}.` + `Retrying ${gateway.name}.`, + `Unable to retry ${gateway.name}.` ); }; @@ -266,7 +266,7 @@ const DeployFeature: FC = ({ port }) => { onDeploy={handleDeploy} onStopGateway={handleStop} onRetryGateway={handleRetry} - onRedeployGateway={handleRedeploy} + onDeleteBuild={handleDeleteBuild} /> ); }; diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx index b19e7fee86..e02d695b19 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx @@ -32,17 +32,47 @@ export type DeployPageProps = { /** The backend URL the API is defined against; the deploy form starts from it. */ apiEndpointUrl?: string; busy: boolean; - /** Deploys to `target`; `from` is set when this is a promotion. */ + /** + * Deploys to `target`; `from` is set when this is a promotion. Every gateway + * goes in one call with its own endpoint, because an environment runs a single + * build of an API at a time. + */ onDeploy: ( target: Environment, - gatewayId: string, - endpointUrl: string, + gateways: { gatewayId: string; endpointUrl?: string }[], from?: Environment, buildId?: string ) => void; onStopGateway: (environment: Environment, gatewayId: string) => void; onRetryGateway: (environment: Environment, gatewayId: string) => void; - onRedeployGateway: (environment: Environment, gatewayId: string) => void; + /** Deletes a build, freeing a slot when the API is at its build limit. */ + onDeleteBuild: (buildId: string) => void; +}; + +/** + * The statuses that mean a gateway is holding a build — on it, going on, or coming + * off. The platform refuses to delete a build in any of them, so the page says so + * up front instead of offering the action and having it rejected. Suspended and + * failed deployments are deliberately absent: their builds ARE deletable, and they + * are the ones automatic cleanup will not reclaim. + */ +const GATEWAY_HELD_STATUSES = ['DEPLOYED', 'DEPLOYING', 'UNDEPLOYING']; + +/** + * Why each build cannot be deleted, by build id, naming the environment that is + * holding it so the reason is actionable rather than just a refusal. + */ +const undeletableBuildReasons = (environments: Environment[]): Record => { + const reasons: Record = {}; + environments.forEach((environment) => { + environment.gateways.forEach((gateway) => { + if (!gateway.buildId || !gateway.status) return; + if (!GATEWAY_HELD_STATUSES.includes(gateway.status)) return; + reasons[gateway.buildId] = + `This build is on a gateway in ${environment.name}. Undeploy it before deleting the build.`; + }); + }); + return reasons; }; /** @@ -66,7 +96,7 @@ const DeployPage: FC = ({ onDeploy, onStopGateway, onRetryGateway, - onRedeployGateway, + onDeleteBuild, }) => { const [dialog, setDialog] = useState(null); @@ -74,8 +104,11 @@ const DeployPage: FC = ({ const source = dialog?.sourceIndex !== undefined ? environments[dialog.sourceIndex] : undefined; - const handleConfirm = (gatewayId: string, endpointUrl: string, buildId?: string) => { - if (target) onDeploy(target, gatewayId, endpointUrl, source, buildId); + const handleConfirm = ( + gateways: { gatewayId: string; endpointUrl?: string }[], + buildId?: string + ) => { + if (target) onDeploy(target, gateways, source, buildId); setDialog(null); }; @@ -152,10 +185,12 @@ const DeployPage: FC = ({ setDialog({ targetIndex: 0, buildId, createBuild }) } + onDeleteBuild={onDeleteBuild} /> {environments.map((environment, index) => ( @@ -170,7 +205,6 @@ const DeployPage: FC = ({ } onStopGateway={(gatewayId) => onStopGateway(environment, gatewayId)} onRetryGateway={(gatewayId) => onRetryGateway(environment, gatewayId)} - onRedeployGateway={(gatewayId) => onRedeployGateway(environment, gatewayId)} /> ))} diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/BuildAreaCard.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/BuildAreaCard.tsx index fc38211e51..24d4956005 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/BuildAreaCard.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/BuildAreaCard.tsx @@ -31,7 +31,7 @@ import { Tooltip, Typography, } from '@wso2/oxygen-ui'; -import { ChevronDown, Clock, X } from '@wso2/oxygen-ui-icons-react'; +import { ChevronDown, Clock, Trash2, X } from '@wso2/oxygen-ui-icons-react'; import { relativeTime } from '../utils/time'; import { activeGatewayCount } from '../utils/status'; import type { Build, Environment } from '../types'; @@ -40,9 +40,20 @@ export type BuildAreaCardProps = { /** The API's builds, newest first. */ builds: Build[]; targetEnvironment: Environment; + /** + * Why each build cannot be deleted, by build id. A build a gateway is serving is + * refused by the platform, so it is shown disabled with the reason rather than + * offered and then rejected. Builds absent from this map are deletable. + */ + undeletableBuilds: Record; busy: boolean; /** Opens deployment using an existing build, or creates a build when omitted. */ onDeployClick: (buildId?: string, createBuild?: boolean) => void; + /** + * Deletes a build. An API keeps a limited number, so this is how room is made + * once deploying is refused for having no free slot. + */ + onDeleteBuild: (buildId: string) => void; }; const VISIBLE_BUILD_COUNT = 5; @@ -55,12 +66,18 @@ type DeployAction = 'deploy' | 'build-and-deploy'; const BuildAreaCard: FC = ({ builds, targetEnvironment, + undeletableBuilds, busy, onDeployClick, + onDeleteBuild, }) => { const [deployMenuAnchor, setDeployMenuAnchor] = useState(null); const [buildsDrawerOpen, setBuildsDrawerOpen] = useState(false); const [selectedDeployAction, setSelectedDeployAction] = useState('deploy'); + // Deleting a build cannot be undone, so the trash icon asks first rather than + // acting. Confirming inline keeps it out of a modal, which the All Builds drawer + // would otherwise have to stack one inside. + const [pendingDelete, setPendingDelete] = useState(null); const latestBuild = builds[0] ?? null; const visibleBuilds = builds.slice(0, VISIBLE_BUILD_COUNT); const canDeploy = activeGatewayCount(targetEnvironment.gateways) > 0; @@ -86,30 +103,85 @@ const BuildAreaCard: FC = ({ const renderBuilds = (items: Build[]) => ( - {items.map((build) => ( - - - - {build.buildId} - - {build.createdAt ? ( - - - - {relativeTime(build.createdAt)} - + {items.map((build) => { + const blockedReason = undeletableBuilds[build.buildId]; + const confirming = pendingDelete === build.buildId; + return ( + + + + + + {build.buildId} + + {build.description ? ( + + {build.description} + + ) : null} + {build.createdAt ? ( + + + + {relativeTime(build.createdAt)} + + + ) : null} + + {confirming ? null : ( + + + setPendingDelete(build.buildId)} + > + + + + + )} - ) : null} - - - ))} + {confirming ? ( + + + Delete this build? + + + + + + + ) : null} + + + ); + })} ); diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/DeployDialog.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/DeployDialog.tsx index e2519e95c6..ec7856650c 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/DeployDialog.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/DeployDialog.tsx @@ -21,15 +21,19 @@ import { Alert, Box, Button, + Checkbox, + Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, + FormControlLabel, FormLabel, MenuItem, Select, TextField, + Tooltip, Typography, } from '@wso2/oxygen-ui'; import StatusDot from './StatusDot'; @@ -51,7 +55,15 @@ export type DeployDialogProps = { createBuild: boolean; submitting: boolean; onClose: () => void; - onConfirm: (gatewayId: string, endpointUrl: string, buildId?: string) => void; + /** + * Confirms the deploy with every selected gateway and its endpoint. Plural + * because an environment runs a single build of an API at a time, so the + * gateways go in one call. + */ + onConfirm: ( + gateways: { gatewayId: string; endpointUrl?: string }[], + buildId?: string + ) => void; }; const sectionLabelSx = { @@ -86,16 +98,16 @@ const DeployDialog: FC = ({ // Holding the resolved values instead would leave the first render of a freshly // opened dialog with nothing selected, since the effect that filled them ran // after it, and would let a background refresh overwrite a half-typed URL. - const [gatewayId, setGatewayId] = useState(''); + const [selectedIds, setSelectedIds] = useState(null); const [buildId, setBuildId] = useState(''); - const [endpointDraft, setEndpointDraft] = useState(null); + const [endpointDrafts, setEndpointDrafts] = useState>({}); const [urlTouched, setUrlTouched] = useState(false); useEffect(() => { if (!open) return; - setGatewayId(''); + setSelectedIds(null); setBuildId(''); - setEndpointDraft(null); + setEndpointDrafts({}); setUrlTouched(false); }, [open]); @@ -109,29 +121,53 @@ const DeployDialog: FC = ({ ) : builds; const selectedBuildId = buildId || initialBuildId || availableBuilds[0]?.buildId || ''; - const selectedGateway = - environment.gateways.find((gateway) => gateway.id === gatewayId) ?? - pickDefaultGateway(environment.gateways); - // What this gateway already serves comes first, so re-deploying to it keeps the - // endpoint it is running; a gateway with nothing on it starts from the API's own - // backend URL rather than an empty field. - const endpointUrl = endpointDraft ?? selectedGateway?.endpointUrl ?? apiEndpointUrl ?? ''; + // A gateway the API is LIVE on must stay in the set: the environment runs one + // build, so this deploy has to reach it too. They are shown ticked and locked, + // and undeploying is the way to drop one. + // + // Keyed on the status, not on deploymentId: a stopped gateway keeps its + // deployment id — that is how it is identified — so testing for the id locked + // gateways that were undeployed, leaving no way to deploy or promote without + // them. The whole point of stopping one is to drop it from the set. + const liveStatuses = ['DEPLOYED', 'DEPLOYING', 'FAILED']; + const alreadyDeployed = environment.gateways.filter((gateway) => + liveStatuses.includes(gateway.status) + ); + const lockedIds = alreadyDeployed.map((gateway) => gateway.id); + + // Until the user touches the list, the selection is the already-deployed + // gateways, or the environment's default for a first deploy. + const defaultSelection = + lockedIds.length > 0 + ? lockedIds + : [pickDefaultGateway(environment.gateways)?.id].filter((id): id is string => !!id); + const selection = selectedIds ?? defaultSelection; + const selected = environment.gateways.filter((gateway) => selection.includes(gateway.id)); + + // What a gateway already serves comes first, so redeploying keeps the endpoint + // it is running; one with nothing on it starts from the API's own backend URL. + const endpointFor = (gateway: Gateway) => + endpointDrafts[gateway.id] ?? gateway.endpointUrl ?? apiEndpointUrl ?? ''; + const isSingleGateway = environment.gateways.length === 1; - // Whether the gateway can receive a deployment is its own health, not the state - // of what is deployed on it: a healthy gateway with nothing deployed is exactly - // what a first deployment targets. - const isSelectedInactive = selectedGateway ? selectedGateway.health !== 'active' : false; - const urlMissing = endpointUrl.trim().length === 0; + // Whether a gateway can receive a deployment is its own health, not the state of + // what is on it: a healthy gateway with nothing deployed is exactly what a first + // deployment targets. + const inactiveSelected = selected.filter((gateway) => gateway.health !== 'active'); + const missingUrls = selected.filter((gateway) => endpointFor(gateway).trim().length === 0); const canConfirm = - !!selectedGateway && - !isSelectedInactive && - !urlMissing && + selected.length > 0 && + inactiveSelected.length === 0 && + missingUrls.length === 0 && (createBuild || selectedBuildId.length > 0); - const handleSelectGateway = (id: string) => { - setGatewayId(id); - setEndpointDraft(null); - setUrlTouched(false); + const toggleGateway = (id: string) => { + // Locked gateways cannot be unticked — the backend refuses a deploy that drops + // them, so offering it here would only produce an error. + if (lockedIds.includes(id)) return; + setSelectedIds( + selection.includes(id) ? selection.filter((each) => each !== id) : [...selection, id] + ); }; return ( @@ -146,115 +182,200 @@ const DeployDialog: FC = ({ : `Carries a build running in ${sourceEnvironment?.name ?? 'the previous environment'} forward to ${environment.name}, with the endpoint you give here.`} - {isSelectedInactive ? ( + {inactiveSelected.length > 0 ? ( - {selectedGateway?.name} is inactive and can't receive a deployment. Choose an active gateway to continue. + {inactiveSelected.map((gateway) => gateway.name).join(', ')} + {inactiveSelected.length === 1 ? ' is inactive and ' : ' are inactive and '} + can't receive a deployment. Unselect{inactiveSelected.length === 1 ? ' it' : ' them'} to + continue. ) : null} - {isSingleGateway && selectedGateway ? ( + {isSingleGateway && selected.length === 1 ? ( Gateway - - - - {selectedGateway.name} - - {selectedGateway.host ? ( - - {selectedGateway.host} + + + + + {selected[0].name} - ) : null} + {selected[0].host ? ( + + {selected[0].host} + + ) : null} + - + + + setEndpointDrafts({ ...endpointDrafts, [selected[0].id]: event.target.value }) + } + onBlur={() => setUrlTouched(true)} + error={urlTouched && endpointFor(selected[0]).trim().length === 0} + helperText={ + urlTouched && endpointFor(selected[0]).trim().length === 0 + ? 'Endpoint URL is required.' + : ' ' + } + /> ) : ( - Gateway - - - + {/* The endpoint is per gateway: two gateways of one environment + can serve different backends, so each selected one gets its + own field rather than sharing a single value. */} + {isSelected ? ( + + setEndpointDrafts({ ...endpointDrafts, [gateway.id]: event.target.value }) + } + onBlur={() => setUrlTouched(true)} + error={urlTouched && endpointFor(gateway).trim().length === 0} + helperText={ + urlTouched && endpointFor(gateway).trim().length === 0 + ? 'Endpoint URL is required.' + : ' ' + } + /> + ) : null} + + ); + })} + )} {createBuild ? null : ( Build - - setBuildId(event.target.value as string)} + displayEmpty + disabled={availableBuilds.length === 0} + > + {availableBuilds.length === 0 ? ( + + No builds available - )) - )} - - + ) : ( + availableBuilds.map((build) => ( + + {build.buildId} + + )) + )} + + + )} )} - - Endpoint URL - setEndpointDraft(event.target.value)} - onBlur={() => setUrlTouched(true)} - error={urlTouched && urlMissing} - helperText={urlTouched && urlMissing ? 'Endpoint URL is required.' : ' '} - /> - diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/EnvironmentCard.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/EnvironmentCard.tsx index ca896a2372..ec9161cb4d 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/EnvironmentCard.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/EnvironmentCard.tsx @@ -30,7 +30,6 @@ export type EnvironmentCardProps = { onPromoteClick: () => void; onStopGateway: (gatewayId: string) => void; onRetryGateway: (gatewayId: string) => void; - onRedeployGateway: (gatewayId: string) => void; }; const sectionLabelSx = { @@ -48,16 +47,36 @@ const EnvironmentCard: FC = ({ onPromoteClick, onStopGateway, onRetryGateway, - onRedeployGateway, }) => { const { gateways } = environment; const activeCount = activeGatewayCount(gateways); const deployed = hasAnyDeployment(gateways); - const canPromote = !!nextEnvironment && activeGatewayCount(nextEnvironment.gateways) > 0; - const promoteDisabledReason = - nextEnvironment && !canPromote - ? `All gateways in ${nextEnvironment.name} are inactive. Activate a gateway before promoting.` - : ''; + // What the environment is running: the distinct builds across the gateways that + // are actually serving. A settling or stopped gateway is not part of what the + // environment serves, so it does not contribute a build here. + const runningBuilds = Array.from( + new Set( + gateways + .filter((gateway) => gateway.status === 'DEPLOYED' && !!gateway.buildId) + .map((gateway) => gateway.buildId as string) + ) + ).sort(); + + // A promotion carries THIS environment's build forward, so there has to be one: + // once every gateway here is stopped the environment is running nothing and the + // backend refuses the promotion. Gating it here means the button does not offer + // an action that can only fail. + const hasBuildToPromote = runningBuilds.length > 0; + const canPromote = + !!nextEnvironment && activeGatewayCount(nextEnvironment.gateways) > 0 && hasBuildToPromote; + + const promoteDisabledReason = !nextEnvironment + ? '' + : !hasBuildToPromote + ? `Nothing is deployed in ${environment.name} to promote. Deploy here first.` + : activeGatewayCount(nextEnvironment.gateways) === 0 + ? `All gateways in ${nextEnvironment.name} are inactive. Activate a gateway before promoting.` + : ''; return ( = ({ - - {environment.name} - - {activeCount} of {gateways.length} gateway{gateways.length === 1 ? '' : 's'} active - + {/* Name on the left, the environment's build on the right: the build is a + property of the ENVIRONMENT now — every gateway in it runs the same one + — so it sits beside the name rather than being read off the rows and + compared, and it uses the space the header was leaving empty. + + More than one build showing means the environment is split, which the + deploy rules are meant to prevent, so it is called out rather than + quietly listing both. */} + + + {environment.name} + + {activeCount} of {gateways.length} gateway{gateways.length === 1 ? '' : 's'} active + + + + Build + {runningBuilds.length === 0 ? ( + + Nothing deployed + + ) : runningBuilds.length === 1 ? ( + + ) : ( + + + {runningBuilds.map((buildId) => ( + + ))} + + + Split across builds + + + )} + @@ -97,7 +159,6 @@ const EnvironmentCard: FC = ({ environmentName={environment.name} busy={busy} onRetry={() => onRetryGateway(gateway.id)} - onRedeploy={() => onRedeployGateway(gateway.id)} onStop={() => onStopGateway(gateway.id)} /> ))} diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/GatewayRow.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/GatewayRow.tsx index 087e6b7686..cb8c9c4340 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/GatewayRow.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/GatewayRow.tsx @@ -18,7 +18,7 @@ import { useState, type FC } from 'react'; import { Box, Button, Card, CardContent, Collapse, Typography } from '@wso2/oxygen-ui'; -import { ChevronDown, ChevronUp, Eye } from '@wso2/oxygen-ui-icons-react'; +import { ChevronDown, ChevronUp, Clock, Eye } from '@wso2/oxygen-ui-icons-react'; import ActionRow from './ActionRow'; import EndpointUrlDrawer from './EndpointUrlDrawer'; import StatusDot from './StatusDot'; @@ -34,7 +34,6 @@ export type GatewayRowProps = { busy: boolean; onRetry: () => void; /** Puts a suspended deployment back on the gateway, unchanged. */ - onRedeploy: () => void; onStop: () => void; }; @@ -43,7 +42,6 @@ const GatewayRow: FC = ({ environmentName, busy, onRetry, - onRedeploy, onStop, }) => { const [expanded, setExpanded] = useState(false); @@ -53,24 +51,27 @@ const GatewayRow: FC = ({ // What the one action button does depends on what the gateway is doing: // - // - suspended (UNDEPLOYED) — put the SAME deployment back, artifact and all; // - failed — deploy its build again, which makes a new deployment; // - serving — stop it. // + // A stopped gateway offers nothing here. Putting one back is a deploy, which + // goes through the deploy dialog so it joins the build the environment is on — + // reviving its old deployment from this row would put that old build back + // while the rest of the environment had moved on. + // // Nothing to do while a deployment is still settling, or where there is none. - const action: 'redeploy' | 'retry' | 'stop' = - gateway.status === 'UNDEPLOYED' ? 'redeploy' : gateway.status === 'FAILED' ? 'retry' : 'stop'; - const actionLabel = action === 'stop' ? 'Stop deployment' : 'Redeploy'; + const action: 'retry' | 'stop' = gateway.status === 'FAILED' ? 'retry' : 'stop'; + const actionLabel = action === 'stop' ? 'Stop deployment' : 'Retry'; const actionDisabled = busy || gateway.status === 'NOT_DEPLOYED' || + gateway.status === 'UNDEPLOYED' || gateway.status === 'DEPLOYING' || gateway.status === 'UNDEPLOYING' || - // Retrying re-deploys the build, so it needs no deployment; the other two act - // on the deployment itself. + // Retrying re-deploys the build, so it needs no deployment; stopping acts on + // the deployment itself. (action !== 'retry' && !gateway.deploymentId); - const handleActionClick = - action === 'redeploy' ? onRedeploy : action === 'retry' ? onRetry : onStop; + const handleActionClick = action === 'retry' ? onRetry : onStop; return ( @@ -117,26 +118,20 @@ const GatewayRow: FC = ({ {gateway.status !== 'NOT_DEPLOYED' ? ( <> - - - - - ID {gateway.buildId} - - - Deployed {gateway.deployedAt ? relativeTime(gateway.deployedAt) : '—'} - - - - + {/* + When the deployment landed, as a plain line rather than a card: the + build it runs is shown once on the environment, so repeating it per + gateway only added a label with nothing beside it whenever the build + had since been reclaimed. + */} + {gateway.deployedAt ? ( + + + + Deployed {relativeTime(gateway.deployedAt)} + + + ) : null} ('GET', `${base}/builds`); return (response?.list ?? []).map((dto) => ({ buildId: dto.buildId, + description: dto.description, createdBy: dto.createdBy, createdAt: dto.createdAt, })); }, + /** + * Deletes one of the API's builds, which is how room is made once an API is at + * its build limit and deploying is refused. + * + * This goes straight to the API's own resource rather than through the project + * path: a build belongs to the API, not to a pipeline, and the platform already + * scopes the call to the caller's organization. Refused with 409 while a gateway + * is serving the build; undeploying releases it. Deployments that ran the build + * survive and stay redeployable from their own artifact, but stop reporting it, + * so they can no longer be promoted onward. + */ + async deleteBuild(buildId: string): Promise { + await apiFetch( + 'DELETE', + `/rest-apis/${encodeURIComponent(apiHandle)}/builds/${encodeURIComponent(buildId)}` + ); + }, + /** * The backend URL the API is defined against, which the deploy and promote * forms start from so a first deployment does not have to be typed out. @@ -128,41 +152,31 @@ export function createDeployClient(apiFetch: ApiFetch, projectHandle: string, ap }, /** - * Deploys to one gateway of an environment with the endpoint it should serve. + * Deploys (or promotes) one build onto every gateway named, each with its own + * endpoint. An environment runs a single build of an API at a time, so this is + * one call rather than one per gateway: the backend puts the same build on all + * of them and rolls every gateway back if any one fails. * - * Deploying without a `buildId` snapshots the API and deploys the new build. - * Supplying `buildId` deploys that existing build. `fromEnvironment` promotes - * instead, carrying that environment's build forward untouched. + * The gateways must include every gateway the API is already deployed on in + * that environment; leaving one out is refused with 409. */ async deploy(input: { environment: string; - gatewayId: string; - endpointUrl?: string; + gateways: { gatewayId: string; endpointUrl?: string }[]; fromEnvironment?: string; buildId?: string; }): Promise { await apiFetch('POST', `${base}/deployments`, { environment: input.environment, - gatewayId: input.gatewayId, + gateways: input.gateways.map((gateway) => ({ + gatewayId: gateway.gatewayId, + ...(gateway.endpointUrl ? { parameters: { productionEndpoint: gateway.endpointUrl } } : {}), + })), ...(input.fromEnvironment ? { fromEnvironment: input.fromEnvironment } : {}), ...(input.buildId ? { buildId: input.buildId } : {}), - ...(input.endpointUrl ? { parameters: { productionEndpoint: input.endpointUrl } } : {}), }); }, - /** - * Serves a suspended deployment again on the same gateway. The deployment is - * immutable, so it comes back exactly as it was — same build, same endpoint — - * and nothing is rendered or built. - */ - async redeploy(environment: string, gatewayId: string, deploymentId: string): Promise { - const query = `?environment=${encodeURIComponent(environment)}&gatewayId=${encodeURIComponent(gatewayId)}`; - await apiFetch( - 'POST', - `${base}/deployments/${encodeURIComponent(deploymentId)}/redeploy${query}` - ); - }, - /** Stops serving one deployment on one gateway; the rest are untouched. */ async undeploy(environment: string, gatewayId: string, deploymentId: string): Promise { const query = `?environment=${encodeURIComponent(environment)}&gatewayId=${encodeURIComponent(gatewayId)}`; diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/types.ts b/portals/cloud-plugins/apip-cloud-ui-deploy/src/types.ts index 129d9bf1ba..92ff6b367b 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/types.ts +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/types.ts @@ -80,6 +80,8 @@ export type Environment = { */ export type Build = { buildId: string; + /** The note recorded when the build was prepared, if any. */ + description?: string; createdAt?: string; createdBy?: string; }; diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx index 3512736ae6..29039f0341 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx @@ -16,17 +16,19 @@ * under the License. */ -import { useState, type FC } from 'react'; +import { useEffect, useState, type FC } from 'react'; import { Box, Button, CircularProgress, FormControl, + FormControlLabel, FormLabel, Grid, PageContent, PageTitle, Stack, + Switch, TextField, Tooltip, } from '@wso2/oxygen-ui'; @@ -44,6 +46,12 @@ export type GatewayFormProps = { /** The gateway types this host offers. A host with only one type gets no picker. */ types: GatewayType[]; environments: Environment[]; + /** + * The gateways already in this host's view. Used only to tell whether the one + * being created would be the first of its type in the chosen environment, and + * so the environment's default. + */ + gateways: Gateway[]; onBack: () => void; /** Returning a promise lets the form keep its submit button busy until the save settles. */ onSubmit: (input: GatewayInput) => void | Promise; @@ -57,6 +65,7 @@ const GatewayForm: FC = ({ gateway, types, environments, + gateways, onBack, onSubmit, }) => { @@ -67,6 +76,10 @@ const GatewayForm: FC = ({ const showTypeField = types.length > 1; const [type, setType] = useState(gateway?.type ?? types[0]); + // Marking is one-way: a default is handed over by marking another gateway, so + // an existing default's switch stays on and disabled rather than offering an + // "unset" that would leave the environment without one. + const [isDefault, setIsDefault] = useState(gateway?.isDefault ?? false); const [name, setName] = useState(gateway?.name ?? ''); const [description, setDescription] = useState(gateway?.description ?? ''); const [environmentId, setEnvironmentId] = useState(gateway?.environmentId ?? ''); @@ -84,6 +97,26 @@ const GatewayForm: FC = ({ nameError ?? (derivedHandle ? `Handle: ${derivedHandle}` : isEdit ? undefined : NAME_HELPER_TEXT); + // The backend makes the first gateway of a type in an environment its default + // whether or not it was asked to, so the switch shows that outcome rather than + // letting the form claim otherwise. It needs an environment to be true of: + // before one is chosen there is nothing to be the first of. + const isFirstOfType = + !isEdit && + environmentId.length > 0 && + !gateways.some( + (candidate) => candidate.environmentId === environmentId && candidate.type === type + ); + + // Re-derived whenever the chosen environment or type changes, so the switch + // always describes the current selection. A manual toggle afterwards sticks: + // `isFirstOfType` does not change when the switch does, so this does not fire + // and undo it. + useEffect(() => { + if (isEdit) return; + setIsDefault(isFirstOfType); + }, [isEdit, isFirstOfType]); + const [submitting, setSubmitting] = useState(false); const missingRequired = name.trim().length === 0 || environmentId.length === 0; @@ -100,6 +133,7 @@ const GatewayForm: FC = ({ description: description.trim() || undefined, type, environmentId, + isDefault, }); } finally { setSubmitting(false); @@ -171,6 +205,26 @@ const GatewayForm: FC = ({ /> + + + setIsDefault(event.target.checked)} + /> + } + label="Default gateway for this environment" + /> + diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx index b1ddf6b346..338db2dc1a 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx @@ -107,12 +107,22 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { void load(); }, [load]); + // A host only ever manages its own kinds of gateway: the AI workspace shows AI + // gateways and their default, the publisher shows regular and event ones and + // theirs. Filtering here rather than in the list keeps the count, the status + // poll and the default badge consistent with what is on screen — the default is + // marked per type, so an AI default is meaningless in the publisher's view. + const visibleGateways = useMemo( + () => gateways.filter((gateway) => gatewayTypes.includes(gateway.type)), + [gateways, gatewayTypes] + ); + // Poll only while there is something to wait for: a gateway that is not yet // active. Once they are all active the interval is torn down, so a settled // list costs nothing. Creating another gateway makes this true again and the // poll restarts. Deliberately keyed on the boolean, not on `gateways`, so a // poll's own result does not reset the interval. - const awaitingStatus = gateways.some((gateway) => gateway.status !== 'active'); + const awaitingStatus = visibleGateways.some((gateway) => gateway.status !== 'active'); useEffect(() => { if (view !== 'list' || !awaitingStatus) return undefined; @@ -206,12 +216,13 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { if (view === 'create' || view === 'edit') { const editingGateway = - view === 'edit' ? gateways.find((gateway) => gateway.id === editingGatewayId) : undefined; + view === 'edit' ? visibleGateways.find((gateway) => gateway.id === editingGatewayId) : undefined; return ( { setView('list'); @@ -230,7 +241,7 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { return ( setView('create')} onEditClick={(gatewayId) => { diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx index a630f5d919..137224ce5c 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx @@ -202,6 +202,18 @@ const GatewaysList: FC = ({ {truncateText(gateway.name, 25)} + {/* The default is per gateway TYPE, so several + gateways in one environment can each be + marked — one per type. */} + {gateway.isDefault ? ( + + ) : null} diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/gatewaysApi.ts b/portals/cloud-plugins/apip-cloud-ui-gateways/src/gatewaysApi.ts index 86bbf1a41c..da5db634b0 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/gatewaysApi.ts +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/gatewaysApi.ts @@ -39,6 +39,7 @@ type ManagedGatewayDTO = { updatedAt?: string; environment?: string; host?: string; + isDefault?: boolean; }; const normalizeType = (functionalityType?: string): GatewayType => @@ -59,6 +60,7 @@ const mapGateway = (dto: ManagedGatewayDTO): Gateway => ({ url: dto.host ?? '', status: dto.isActive ? 'active' : 'inactive', isCritical: dto.isCritical ?? false, + isDefault: dto.isDefault ?? false, version: dto.version, createdAt: dto.createdAt ?? '', updatedAt: dto.updatedAt ?? '', @@ -86,14 +88,18 @@ export function createGatewaysClient(apiFetch: ApiFetch) { functionalityType: input.type, description: input.description, isCritical: false, + isDefault: input.isDefault ?? false, }); }, async updateGateway(id: string, input: GatewayInput): Promise { - // Only display name and description are mutable; environment, type, host - // and version are fixed at creation and rejected by the update endpoint. + // Only display name, description and the default marking are mutable; + // environment, type, host and version are fixed at creation and rejected by + // the update endpoint. isDefault is sent only when asking for the default: + // the API treats false as "leave it alone", never as "clear it". await apiFetch('PUT', `/managed-gateways/${encodeURIComponent(id)}`, { displayName: input.name, description: input.description, + ...(input.isDefault ? { isDefault: true } : {}), }); }, async deleteGateway(id: string): Promise { diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/types.ts b/portals/cloud-plugins/apip-cloud-ui-gateways/src/types.ts index f12afbc00c..8e827dfe4e 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/types.ts +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/types.ts @@ -47,6 +47,12 @@ export type Gateway = { url: string; status: GatewayStatus; isCritical: boolean; + /** + * Whether this gateway is the one its environment resolves to for its type. + * One gateway per environment and type carries it, so an AI gateway being the + * default says nothing about the regular ones in the same environment. + */ + isDefault: boolean; version?: string; createdAt: string; updatedAt: string; @@ -54,12 +60,19 @@ export type Gateway = { /** * Fields the create/edit form collects. `id`/`url` (host) are server-assigned; - * on edit only `name`/`description` are mutable (`type`/`environmentId` are - * fixed at creation). + * on edit only `name`/`description`/`isDefault` are mutable (`type` and + * `environmentId` are fixed at creation). */ export type GatewayInput = { name: string; description?: string; type: GatewayType; environmentId: string; + /** + * Ask for this gateway to be its environment's default for its type. Only a + * true value acts: the default is handed over by marking another gateway, + * never by clearing this one, so an environment always keeps a default for a + * type it has gateways of. + */ + isDefault?: boolean; }; diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelineCreatePage.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelineCreatePage.tsx index 295918a8d1..2cfed79876 100644 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelineCreatePage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelineCreatePage.tsx @@ -21,7 +21,7 @@ import { Typography, } from '@wso2/oxygen-ui'; import { ArrowRight, ChevronLeft, Plus } from '@wso2/oxygen-ui-icons-react'; -import EnvironmentGatewayPicker from './components/EnvironmentGatewayPicker'; +import EnvironmentPicker from './components/EnvironmentPicker'; import PipelineStageCard from './components/PipelineStageCard'; import type { CreatePipelineInput, Environment, Pipeline } from './types'; import { orderEnvironments } from './utils'; @@ -38,15 +38,14 @@ export type PipelineCreatePageProps = { }; /** - * The linear chain the builder edits: environments in promotion order, each with - * the gateway marked as its default. This is the builder's own working view over - * the API shape — on submit it emits `promotionPaths` (consecutive pairs) and - * `defaultGateways` directly; nothing else converts pipeline data. + * The linear chain the builder edits: environments in promotion order. This is + * the builder's own working view over the API shape — on submit it emits + * `promotionPaths` (consecutive pairs) directly; nothing else converts pipeline + * data. */ type ChainEntry = { /** Environment name — the identifier the API uses everywhere. */ environment: string; - defaultGatewayId: string; }; /** Shown until the name breaks a rule, so the constraint is known up front. */ @@ -56,19 +55,9 @@ const NAME_HELPER_TEXT = const findEnvironment = (environments: Environment[], name: string) => environments.find((environment) => environment.name === name); -const findGateway = (environment: Environment | undefined, gatewayId: string) => - environment?.gateways.find((gateway) => gateway.id === gatewayId); - /** Reconstructs the builder's chain from an existing pipeline's promotion graph. */ -const toChain = (pipeline: Pipeline, environments: Environment[]): ChainEntry[] => - orderEnvironments(pipeline.promotionPaths).map((name) => { - const environment = findEnvironment(environments, name); - const marked = pipeline.defaultGateways.find((entry) => entry.environment === name)?.gatewayId; - return { - environment: name, - defaultGatewayId: marked ?? (environment?.gateways.length === 1 ? environment.gateways[0].id : ''), - }; - }); +const toChain = (pipeline: Pipeline): ChainEntry[] => + orderEnvironments(pipeline.promotionPaths).map((name) => ({ environment: name })); const PipelineCreatePage: FC = ({ environments, @@ -80,7 +69,7 @@ const PipelineCreatePage: FC = ({ const isEdit = mode === 'edit' && !!initialPipeline; const [name, setName] = useState(initialPipeline?.name ?? ''); const [chain, setChain] = useState( - initialPipeline ? toChain(initialPipeline, environments) : [] + initialPipeline ? toChain(initialPipeline) : [] ); const [pickerOpen, setPickerOpen] = useState(false); const [pickerAnchor, setPickerAnchor] = useState(null); @@ -96,8 +85,8 @@ const PipelineCreatePage: FC = ({ setPickerOpen(true); }; - const handleAddEnvironment = (environment: string, defaultGatewayId: string) => { - const entry: ChainEntry = { environment, defaultGatewayId }; + const handleAddEnvironment = (environment: string) => { + const entry: ChainEntry = { environment }; setChain((prev) => { const index = insertIndexRef.current; if (index === null || index >= prev.length) return [...prev, entry]; @@ -123,21 +112,14 @@ const PipelineCreatePage: FC = ({ // first request is still in flight. if (saving) return; // The linear chain is emitted as the API shape directly: consecutive pairs - // become promotion paths, and only multi-gateway environments carry an - // explicit default (single-gateway environments default implicitly). + // become promotion paths. const promotionPaths = chain.slice(0, -1).map((entry, index) => ({ sourceEnvironment: entry.environment, targetEnvironments: [chain[index + 1].environment], })); - const defaultGateways = chain - .filter((entry) => { - const environment = findEnvironment(environments, entry.environment); - return !!environment && environment.gateways.length > 1 && !!entry.defaultGatewayId; - }) - .map((entry) => ({ environment: entry.environment, gatewayId: entry.defaultGatewayId })); setSaving(true); try { - await onSubmit({ name: name.trim(), promotionPaths, defaultGateways }, initialPipeline?.id); + await onSubmit({ name: name.trim(), promotionPaths }, initialPipeline?.id); } finally { setSaving(false); } @@ -201,7 +183,6 @@ const PipelineCreatePage: FC = ({ <> {chain.map((entry, index) => { const environment = findEnvironment(environments, entry.environment); - const gateway = findGateway(environment, entry.defaultGatewayId); return ( {index > 0 ? ( @@ -218,7 +199,6 @@ const PipelineCreatePage: FC = ({ ) : null} handleRemoveEnvironment(entry.environment)} /> @@ -241,7 +221,7 @@ const PipelineCreatePage: FC = ({ )} - & { id: string; name: string }; @@ -36,7 +34,7 @@ type PipelineListDTO = { count?: number; list?: PipelineDTO[] }; * The extension's `render(port)` result: an organization-scoped list/create/edit * flow over the platform-api deployment pipelines, switching view with local * state rather than a nested route. Pipelines are held in the API shape - * (`promotionPaths` + `defaultGateways`) and sent back verbatim; the host-injected + * (`promotionPaths`) and sent back verbatim; the host-injected * `apiFetch` is the only transport — the component never sees a token or a URL. */ const PipelinesFeature: FC = ({ port }) => { @@ -56,15 +54,9 @@ const PipelinesFeature: FC = ({ port }) => { setLoading(true); setError(null); try { - const [environmentList, gatewayList] = await Promise.all([ - apiFetch('GET', '/environments'), - apiFetch('GET', '/managed-gateways'), - ]); + const environmentList = await apiFetch('GET', '/environments'); const pipelineList = await apiFetch('GET', '/pipelines'); - const assembledEnvironments = assembleEnvironments( - environmentList?.list ?? [], - gatewayList?.list ?? [] - ); + const assembledEnvironments = assembleEnvironments(environmentList?.list ?? []); setEnvironments(assembledEnvironments); setPipelines( (pipelineList?.list ?? []).map((dto) => { @@ -72,7 +64,6 @@ const PipelinesFeature: FC = ({ port }) => { id: dto.id, name: dto.name, promotionPaths: dto.promotionPaths ?? [], - defaultGateways: dto.defaultGateways ?? [], }; return { ...base, diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesListPage.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesListPage.tsx index 650c8cb4de..f2bdd1246d 100644 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesListPage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesListPage.tsx @@ -35,7 +35,7 @@ import { } from '@wso2/oxygen-ui-icons-react'; import PipelineStageCard from './components/PipelineStageCard'; import type { Environment, Pipeline } from './types'; -import { isLinearPipeline, orderEnvironments, resolveGatewayName } from './utils'; +import { isLinearPipeline, orderEnvironments } from './utils'; export type PipelinesListPageProps = { pipelines: Pipeline[]; @@ -221,7 +221,6 @@ const PipelinesListPage: FC = ({ ) : null} diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesFeature.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesFeature.tsx index 05ace0d88b..460901b12a 100644 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesFeature.tsx @@ -17,7 +17,6 @@ import { buildStages, DEFAULT_PIPELINE_NAME, type EnvironmentDTO, - type ManagedGatewayDTO, } from './utils'; export type ProjectPipelinesFeatureProps = { @@ -25,7 +24,6 @@ export type ProjectPipelinesFeatureProps = { }; type EnvironmentListDTO = { count?: number; list?: EnvironmentDTO[] }; -type ManagedGatewayListDTO = { list?: ManagedGatewayDTO[] }; type PipelineDTO = Partial & { id: string; name: string }; type PipelineListDTO = { count?: number; list?: PipelineDTO[] }; type ProjectPipelineDTO = { pipeline?: string }; @@ -56,10 +54,7 @@ const ProjectPipelinesFeature: FC = ({ port }) => setLoading(true); setError(null); try { - const [environmentList, gatewayList] = await Promise.all([ - apiFetch('GET', '/environments'), - apiFetch('GET', '/managed-gateways'), - ]); + const environmentList = await apiFetch('GET', '/environments'); const pipelineList = await apiFetch('GET', '/pipelines'); // The binding endpoint returns 200 with an empty `pipeline` for a project // that has no binding yet, so absence is a successful empty read — not an @@ -73,10 +68,7 @@ const ProjectPipelinesFeature: FC = ({ port }) => // The binding stores the pipeline's OpenChoreo resource name — `Pipeline.id` // here — not its display name. const bound = binding?.pipeline ?? ''; - const assembledEnvironments = assembleEnvironments( - environmentList?.list ?? [], - gatewayList?.list ?? [] - ); + const assembledEnvironments = assembleEnvironments(environmentList?.list ?? []); setEnvironments(assembledEnvironments); setPipelines( (pipelineList?.list ?? []).map((dto) => { @@ -84,7 +76,6 @@ const ProjectPipelinesFeature: FC = ({ port }) => id: dto.id, name: dto.name, promotionPaths: dto.promotionPaths ?? [], - defaultGateways: dto.defaultGateways ?? [], }; return { ...base, diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesPage.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesPage.tsx index 055273a3ee..6df1935329 100644 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesPage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/ProjectPipelinesPage.tsx @@ -162,9 +162,6 @@ const ProjectPipelinesPage: FC = ({ {selectedPipeline.stages.map((stage, index) => { const environment = environments.find((item) => item.id === stage.environmentId); - const gateway = environment?.gateways.find( - (item) => item.id === stage.defaultGatewayId - ); return ( = ({ ) : null} diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentGatewayPicker.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentGatewayPicker.tsx deleted file mode 100644 index 3fdcc83e0b..0000000000 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentGatewayPicker.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved. - * - * This software is the property of WSO2 LLC and its suppliers, if any. - * Dissemination of any information or reproduction of any material contained - * herein in any form is strictly forbidden, unless permitted by WSO2 expressly. - * You may not alter or remove any copyright or other notice from copies of this content. - */ - -import { useEffect, useState, type FC } from 'react'; -import { - Box, - Button, - IconButton, - List, - ListItem, - ListItemButton, - ListItemText, - Popover, - Switch, - Tooltip, - Typography, -} from '@wso2/oxygen-ui'; -import { ChevronLeft } from '@wso2/oxygen-ui-icons-react'; - -import type { Environment } from '../types'; - -export type EnvironmentGatewayPickerProps = { - open: boolean; - anchorEl: HTMLElement | null; - /** All environments the pipeline could target. */ - environments: Environment[]; - /** Environment names already in this pipeline — offered but disabled, not hidden, so the count stays legible. One pipeline may use a given environment at most once. */ - usedEnvironments: string[]; - onClose: () => void; - /** - * Called once the user confirms adding an environment — `defaultGatewayId` is - * whichever gateway they toggled on. An environment with exactly one gateway - * skips straight to this call with that gateway as the default — no toggle - * step to show. The environment is identified by name (the API's identifier). - */ - onAdd: (environmentName: string, defaultGatewayId: string) => void; -}; - -/** - * The environment picker: step 1 always shows environments; step 2 (marking - * the default gateway) only appears when the chosen environment has more - * than one gateway — with exactly one, it's the default automatically and - * step 2 is skipped. - */ -const EnvironmentGatewayPicker: FC = ({ - open, - anchorEl, - environments, - usedEnvironments, - onClose, - onAdd, -}) => { - const [selectedEnvironmentName, setSelectedEnvironmentName] = useState(null); - const [defaultGatewayId, setDefaultGatewayId] = useState(null); - - useEffect(() => { - if (!open) { - setSelectedEnvironmentName(null); - setDefaultGatewayId(null); - } - }, [open]); - - const selectedEnvironment = - environments.find((env) => env.name === selectedEnvironmentName) ?? null; - - const isEnvironmentUsed = (name: string) => usedEnvironments.includes(name); - - const handleSelectEnvironment = (environment: Environment) => { - // An environment with no gateways cannot be added — it is disabled in the - // list, but guard here too so a stray call never silently closes the picker. - if (environment.gateways.length === 0) return; - if (environment.gateways.length === 1) { - onAdd(environment.name, environment.gateways[0].id); - onClose(); - return; - } - setSelectedEnvironmentName(environment.name); - setDefaultGatewayId(environment.gateways[0]?.id ?? null); - }; - - const handleConfirmAdd = () => { - if (!selectedEnvironment || !defaultGatewayId) return; - onAdd(selectedEnvironment.name, defaultGatewayId); - onClose(); - }; - - return ( - - - {selectedEnvironment ? ( - <> - - setSelectedEnvironmentName(null)} - > - - - - Mark default gateway in {selectedEnvironment.name} - - - - {selectedEnvironment.gateways.map((gateway) => ( - setDefaultGatewayId(gateway.id)} - inputProps={{ 'aria-label': `Mark ${gateway.name} as default` }} - /> - } - > - - - ))} - - - - - - - - - - ) : ( - <> - - Select Environment - - - {environments.map((environment) => { - const used = isEnvironmentUsed(environment.name); - const noGateways = environment.gateways.length === 0; - const disabled = used || noGateways; - return ( - - - handleSelectEnvironment(environment)} - > - - - - - ); - })} - {environments.every((environment) => isEnvironmentUsed(environment.name)) ? ( - - All environments have been added. - - ) : null} - - - )} - - - ); -}; - -export default EnvironmentGatewayPicker; diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentPicker.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentPicker.tsx new file mode 100644 index 0000000000..5bb4210cbd --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentPicker.tsx @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved. + * + * This software is the property of WSO2 LLC and its suppliers, if any. + * Dissemination of any information or reproduction of any material contained + * herein in any form is strictly forbidden, unless permitted by WSO2 expressly. + * You may not alter or remove any copyright or other notice from copies of this content. + */ + +import { type FC } from 'react'; +import { + Box, + List, + ListItemButton, + ListItemText, + Popover, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; + +import type { Environment } from '../types'; + +export type EnvironmentPickerProps = { + open: boolean; + anchorEl: HTMLElement | null; + /** All environments the pipeline could target. */ + environments: Environment[]; + /** Environment names already in this pipeline — offered but disabled, not hidden, so the count stays legible. One pipeline may use a given environment at most once. */ + usedEnvironments: string[]; + onClose: () => void; + /** Called with the chosen environment's name (the API's identifier for it). */ + onAdd: (environmentName: string) => void; +}; + +/** + * The environment picker. A pipeline is a promotion order over environments and + * nothing else — which gateway an environment deploys to is settled on the + * gateway itself, when it is onboarded — so this is a single-step list with no + * gateway involved. + */ +const EnvironmentPicker: FC = ({ + open, + anchorEl, + environments, + usedEnvironments, + onClose, + onAdd, +}) => { + const isEnvironmentUsed = (name: string) => usedEnvironments.includes(name); + + const handleSelectEnvironment = (environment: Environment) => { + onAdd(environment.name); + onClose(); + }; + + return ( + + + + Select Environment + + + {environments.map((environment) => { + const used = isEnvironmentUsed(environment.name); + return ( + + + handleSelectEnvironment(environment)} + > + + + + + ); + })} + {environments.every((environment) => isEnvironmentUsed(environment.name)) ? ( + + All environments have been added. + + ) : null} + + + + ); +}; + +export default EnvironmentPicker; diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/PipelineStageCard.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/PipelineStageCard.tsx index dd29e1ba47..a9ee60e510 100644 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/PipelineStageCard.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/PipelineStageCard.tsx @@ -13,15 +13,12 @@ import { X } from '@wso2/oxygen-ui-icons-react'; export type PipelineStageCardProps = { environmentName: string; - /** The stage's default gateway name — the only one shown, even when the environment has others. */ - gatewayName: string; critical?: boolean; onRemove?: () => void; }; const PipelineStageCard: FC = ({ environmentName, - gatewayName, critical, onRemove, }) => { @@ -56,9 +53,6 @@ const PipelineStageCard: FC = ({ /> ) : null} - - {gatewayName} - {onRemove ? ( { }; /** - * The gateway name shown for an environment in a pipeline: the one marked - * default, or the environment's single gateway when it has exactly one - * (defaulted implicitly). Falls back to the raw id if the reference data has no - * matching gateway. - */ -export const resolveGatewayName = ( - pipeline: Pipeline, - environments: Environment[], - environmentName: string -): string => { - const environment = environments.find((candidate) => candidate.name === environmentName); - const markedId = pipeline.defaultGateways.find( - (entry) => entry.environment === environmentName - )?.gatewayId; - const gatewayId = - markedId ?? (environment?.gateways.length === 1 ? environment.gateways[0].id : ''); - return environment?.gateways.find((gateway) => gateway.id === gatewayId)?.name ?? gatewayId; -}; - -/** - * The default gateway id for one environment of a pipeline: the one marked - * default, or the environment's single gateway when it has exactly one - * (defaulted implicitly), or '' otherwise. - */ -export const resolveDefaultGatewayId = ( - pipeline: { defaultGateways: DefaultGateway[] }, - environments: Environment[], - environmentName: string -): string => { - const environment = environments.find((candidate) => candidate.name === environmentName); - const markedId = pipeline.defaultGateways.find( - (entry) => entry.environment === environmentName - )?.gatewayId; - return markedId ?? (environment?.gateways.length === 1 ? environment.gateways[0].id : ''); -}; - -/** - * Projects a pipeline's `promotionPaths` + `defaultGateways` into the - * promotion-ordered `PipelineStage[]` the stage-card chain renders. Environments - * are resolved to their assembled ids so the cards can look up the environment and - * its default gateway. + * Projects a pipeline's `promotionPaths` into the promotion-ordered + * `PipelineStage[]` the stage-card chain renders. Environments are resolved to + * their assembled ids so the cards can look the environment up. */ export const buildStages = ( - pipeline: { promotionPaths: PromotionPath[]; defaultGateways: DefaultGateway[] }, + pipeline: { promotionPaths: PromotionPath[] }, environments: Environment[] ): PipelineStage[] => orderEnvironments(pipeline.promotionPaths).map((environmentName) => { const environmentId = environments.find((environment) => environment.name === environmentName)?.id ?? environmentName; - return { - id: environmentId, - environmentId, - defaultGatewayId: resolveDefaultGatewayId(pipeline, environments, environmentName), - }; + return { id: environmentId, environmentId }; }); /** Reference-data shapes returned by the platform-api list endpoints. */ export type EnvironmentDTO = { id?: string; name: string; isProduction?: boolean }; -export type ManagedGatewayDTO = { - id: string; - environment: string; - host?: string; - /** The gateway's human-friendly display name; preferred over the host for labels. */ - displayName?: string; -}; /** - * Joins `/environments` with `/managed-gateways` (grouped by environment name) - * into the reference `Environment[]` the picker and cards render. Reference-data - * assembly for lookups — it does not touch the pipeline shape. + * Maps `/environments` into the reference `Environment[]` the picker and cards + * render. Reference-data assembly for lookups — it does not touch the pipeline + * shape, and it needs no gateway data: a pipeline names environments only. */ -export const assembleEnvironments = ( - environments: EnvironmentDTO[], - gateways: ManagedGatewayDTO[] -): Environment[] => +export const assembleEnvironments = (environments: EnvironmentDTO[]): Environment[] => environments.map((environment) => ({ id: environment.id ?? environment.name, name: environment.name, critical: environment.isProduction ?? false, - gateways: gateways - .filter((gateway) => gateway.environment === environment.name) - .map((gateway): Gateway => ({ - id: gateway.id, - name: gateway.displayName || gateway.host || gateway.id, - })), }));