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..cb5b527337 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,62 @@ 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'); + // A locked gateway cannot be unticked, so telling its owner to unselect it would + // be an instruction they cannot follow. Both still block — deploying an + // environment is one call that rolls every gateway back if any fails, so letting + // it through would only trade a dead end for a failed deploy — but the way out + // differs, so the two are said separately. + const inactiveLocked = inactiveSelected.filter((gateway) => lockedIds.includes(gateway.id)); + const inactiveSelectable = inactiveSelected.filter( + (gateway) => !lockedIds.includes(gateway.id) + ); + 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 +191,210 @@ const DeployDialog: FC = ({ : `Carries a build running in ${sourceEnvironment?.name ?? 'the previous environment'} forward to ${environment.name}, with the endpoint you give here.`} - {isSelectedInactive ? ( + {inactiveSelectable.length > 0 ? ( + + {inactiveSelectable.map((gateway) => gateway.name).join(', ')} + {inactiveSelectable.length === 1 ? ' is inactive and ' : ' are inactive and '} + can't receive a deployment. Unselect + {inactiveSelectable.length === 1 ? ' it' : ' them'} to continue. + + ) : null} + + {inactiveLocked.length > 0 ? ( - {selectedGateway?.name} is inactive and can't receive a deployment. Choose an active gateway to continue. + {inactiveLocked.map((gateway) => gateway.name).join(', ')} + {inactiveLocked.length === 1 + ? ' is inactive and already has this API deployed on it, so it cannot be left out of this deployment. Activate it, or stop its deployment in ' + : ' are inactive and already have this API deployed on them, so they cannot be left out of this deployment. Activate them, or stop their deployments in '} + {environment.name}, 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 8ffbdb51c8..29039f0341 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx @@ -211,10 +211,14 @@ const GatewayForm: FC = ({ control={ setIsDefault(event.target.checked)} /> } 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 8a3bb3520a..e733a76cf3 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx @@ -210,7 +210,13 @@ const GatewaysList: FC = ({ gateways in one environment can each be marked — one per type. */} {gateway.isDefault ? ( - + ) : null} diff --git a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesFeature.tsx b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesFeature.tsx index 8460cd6909..ff4730f745 100644 --- a/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-pipelines/src/PipelinesFeature.tsx @@ -54,8 +54,12 @@ const PipelinesFeature: FC = ({ port }) => { setLoading(true); setError(null); try { - const environmentList = await apiFetch('GET', '/environments'); - const pipelineList = await apiFetch('GET', '/pipelines'); + // Independent of each other, so they go together rather than one after the + // other: the page waits for the slower of the two instead of their sum. + const [environmentList, pipelineList] = await Promise.all([ + apiFetch('GET', '/environments'), + apiFetch('GET', '/pipelines'), + ]); const assembledEnvironments = assembleEnvironments(environmentList?.list ?? []); setEnvironments(assembledEnvironments); setPipelines(