From bfad3109513b0832f80bebf76f23cb84d060928c Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Thu, 10 Sep 2026 10:05:07 +0530 Subject: [PATCH 01/10] Choose a gateway's default at onboarding instead of in the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A default gateway is settled on the gateway now, so the pipeline builder drops its gateway step: the picker is a single-step environment list, the stage cards show only the environment, and the pipelines feature no longer reads /managed-gateways at all. The gateways feature gains the other half: a default switch on the create form, a Default badge in the list, and a mark-as-default action that hands the default over (never clears it, so an environment keeps one). The list is now filtered to the host's own gateway types, so the AI workspace shows AI gateways with the AI default while the publisher shows regular and event ones with theirs — until now each host listed every gateway regardless of type. Marking a default resends the gateway's current name and description because the update endpoint replaces the mutable fields rather than patching them. Co-Authored-By: Claude Opus 4.8 --- .../src/GatewayForm.tsx | 27 +++ .../src/GatewaysFeature.tsx | 42 +++- .../src/GatewaysList.tsx | 48 +++- .../apip-cloud-ui-gateways/src/gatewaysApi.ts | 24 +- .../apip-cloud-ui-gateways/src/types.ts | 17 +- .../src/PipelineCreatePage.tsx | 46 ++-- .../src/PipelinesFeature.tsx | 15 +- .../src/PipelinesListPage.tsx | 3 +- .../src/ProjectPipelinesFeature.tsx | 13 +- .../src/ProjectPipelinesPage.tsx | 4 - .../components/EnvironmentGatewayPicker.tsx | 211 ------------------ .../src/components/EnvironmentPicker.tsx | 104 +++++++++ .../src/components/PipelineStageCard.tsx | 6 - .../apip-cloud-ui-pipelines/src/index.ts | 2 - .../apip-cloud-ui-pipelines/src/types.ts | 49 ++-- .../apip-cloud-ui-pipelines/src/utils.ts | 85 +------ 16 files changed, 296 insertions(+), 400 deletions(-) delete mode 100644 portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentGatewayPicker.tsx create mode 100644 portals/cloud-plugins/apip-cloud-ui-pipelines/src/components/EnvironmentPicker.tsx 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..f38f083cb4 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx @@ -22,17 +22,21 @@ import { Button, CircularProgress, FormControl, + FormControlLabel, FormLabel, Grid, PageContent, PageTitle, Stack, + Switch, TextField, Tooltip, + Typography, } from '@wso2/oxygen-ui'; import { ChevronLeft } from '@wso2/oxygen-ui-icons-react'; import EnvironmentSelect from './components/EnvironmentSelect'; import GatewayTypeSelector from './components/GatewayTypeSelector'; +import { gatewayTypeLabel } from './utils/gateway'; import { gatewayHandleFromName, validateGatewayName } from './utils/name'; import type { Environment, Gateway, GatewayInput, GatewayType } from './types'; @@ -67,6 +71,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 ?? ''); @@ -100,6 +108,7 @@ const GatewayForm: FC = ({ description: description.trim() || undefined, type, environmentId, + isDefault, }); } finally { setSubmitting(false); @@ -159,6 +168,24 @@ const GatewayForm: FC = ({ + + setIsDefault(event.target.checked)} + /> + } + label="Default gateway for this environment" + /> + + {gateway?.isDefault + ? 'This is the default. To move it, mark another gateway of the same type as the default.' + : `APIs deploy here by default when no gateway is chosen. One ${gatewayTypeLabel(type)} gateway per environment can be the default; marking this one takes it over from whichever holds it now.`} + + + 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..9204134c04 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; @@ -152,6 +162,31 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { [client, load, notify] ); + // Handing the default over is an update on the gateway itself, so it goes + // through the same client as any other edit. The list refreshes afterwards + // because the previous holder's badge has to clear too, not just this one's. + const markDefault = useCallback( + async (id: string, name: string) => { + if (submittingRef.current) return; + const gateway = visibleGateways.find((candidate) => candidate.id === id); + if (!gateway) return; + submittingRef.current = true; + try { + await client.markGatewayDefault(gateway); + notify(`"${name}" is now the default gateway for its environment.`, 'success'); + await load(); + } catch (markError) { + notify( + markError instanceof Error ? markError.message : 'Unable to mark the gateway as default.', + 'error' + ); + } finally { + submittingRef.current = false; + } + }, + [client, visibleGateways, load, notify] + ); + const removeGateway = useCallback( async (id: string, name: string) => { // One delete at a time: the confirm dialog closes on confirm, so a second @@ -206,7 +241,7 @@ 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 ( = ({ port, gatewayTypes }) => { return ( setView('create')} onEditClick={(gatewayId) => { @@ -238,6 +273,7 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { setView('edit'); }} onDelete={removeGateway} + onMarkDefault={markDefault} /> ); }; 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..3629a603e0 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx @@ -42,9 +42,10 @@ import { TableHead, TableRow, TextField, + Tooltip, Typography, } from '@wso2/oxygen-ui'; -import { Edit, Plus, Search, Settings, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { Edit, Plus, Search, Settings, Star, Trash2 } from '@wso2/oxygen-ui-icons-react'; import GatewaySettingsDrawer from './components/GatewaySettingsDrawer'; import { gatewayTypeLabel } from './utils/gateway'; import NoGatewaysImage from './assets/images/NoGW.svg'; @@ -57,6 +58,8 @@ export type GatewaysListProps = { onEditClick: (gatewayId: string) => void; /** Returning a promise lets the confirm dialog stay open, and busy, until the delete settles. */ onDelete: (gatewayId: string, name: string) => void | Promise; + /** Hand the environment's default for this gateway's type over to it. */ + onMarkDefault: (gatewayId: string, name: string) => void | Promise; }; function truncateText(text: string, maxLength: number): string { @@ -70,11 +73,23 @@ const GatewaysList: FC = ({ onAddClick, onEditClick, onDelete, + onMarkDefault, }) => { const [searchQuery, setSearchQuery] = useState(''); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); const [deleting, setDeleting] = useState(false); const [settingsGateway, setSettingsGateway] = useState(null); + const [markingDefaultId, setMarkingDefaultId] = useState(null); + + const handleMarkDefault = async (gateway: Gateway) => { + if (markingDefaultId) return; + setMarkingDefaultId(gateway.id); + try { + await onMarkDefault(gateway.id, gateway.name); + } finally { + setMarkingDefaultId(null); + } + }; // Environments are keyed by name, so a gateway that points at an environment // missing from the list (deleted, or not yet loaded) still shows its raw @@ -202,6 +217,12 @@ 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} @@ -235,6 +256,31 @@ const GatewaysList: FC = ({ + {/* Only offered for a gateway that is not already + the default: a default is handed over by + marking another, never cleared. */} + {gateway.isDefault ? null : ( + + + void handleMarkDefault(gateway)} + aria-label={`Mark ${gateway.name} as the default gateway`} + > + {markingDefaultId === gateway.id ? ( + + ) : ( + + )} + + + + )} onEditClick(gateway.id)} aria-label={`Edit ${gateway.name}`}> 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..fc8d174f78 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,32 @@ 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 } : {}), + }); + }, + /** + * Hands the environment's default for this gateway's type over to it. + * + * The update endpoint replaces the mutable fields rather than patching them, + * so the gateway's current name and description are resent alongside the + * marking: a body carrying only `isDefault` would blank the description. + */ + async markGatewayDefault(gateway: Gateway): Promise { + await apiFetch('PUT', `/managed-gateways/${encodeURIComponent(gateway.id)}`, { + displayName: gateway.name, + description: gateway.description, + 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, - })), })); From de1f8223545d9ff1f4ae962ece47e3cc25357d44 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Thu, 10 Sep 2026 18:28:23 +0530 Subject: [PATCH 02/10] Mark a gateway default from its form alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the star action from the gateways list and the explanatory text under the form's switch. The switch is the only way to mark a default now, which is enough on its own, so the list's row actions go back to edit, configure and delete — the Default badge still shows which gateway holds it. Co-Authored-By: Claude Opus 4.8 --- .../src/GatewayForm.tsx | 7 ---- .../src/GatewaysFeature.tsx | 26 ------------ .../src/GatewaysList.tsx | 42 +------------------ .../apip-cloud-ui-gateways/src/gatewaysApi.ts | 14 ------- 4 files changed, 1 insertion(+), 88 deletions(-) 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 f38f083cb4..1be68f4356 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx @@ -31,12 +31,10 @@ import { Switch, TextField, Tooltip, - Typography, } from '@wso2/oxygen-ui'; import { ChevronLeft } from '@wso2/oxygen-ui-icons-react'; import EnvironmentSelect from './components/EnvironmentSelect'; import GatewayTypeSelector from './components/GatewayTypeSelector'; -import { gatewayTypeLabel } from './utils/gateway'; import { gatewayHandleFromName, validateGatewayName } from './utils/name'; import type { Environment, Gateway, GatewayInput, GatewayType } from './types'; @@ -179,11 +177,6 @@ const GatewayForm: FC = ({ } label="Default gateway for this environment" /> - - {gateway?.isDefault - ? 'This is the default. To move it, mark another gateway of the same type as the default.' - : `APIs deploy here by default when no gateway is chosen. One ${gatewayTypeLabel(type)} gateway per environment can be the default; marking this one takes it over from whichever holds it now.`} - 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 9204134c04..a6aa4a8296 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx @@ -162,31 +162,6 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { [client, load, notify] ); - // Handing the default over is an update on the gateway itself, so it goes - // through the same client as any other edit. The list refreshes afterwards - // because the previous holder's badge has to clear too, not just this one's. - const markDefault = useCallback( - async (id: string, name: string) => { - if (submittingRef.current) return; - const gateway = visibleGateways.find((candidate) => candidate.id === id); - if (!gateway) return; - submittingRef.current = true; - try { - await client.markGatewayDefault(gateway); - notify(`"${name}" is now the default gateway for its environment.`, 'success'); - await load(); - } catch (markError) { - notify( - markError instanceof Error ? markError.message : 'Unable to mark the gateway as default.', - 'error' - ); - } finally { - submittingRef.current = false; - } - }, - [client, visibleGateways, load, notify] - ); - const removeGateway = useCallback( async (id: string, name: string) => { // One delete at a time: the confirm dialog closes on confirm, so a second @@ -273,7 +248,6 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { setView('edit'); }} onDelete={removeGateway} - onMarkDefault={markDefault} /> ); }; 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 3629a603e0..5a83a65c15 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx @@ -42,10 +42,9 @@ import { TableHead, TableRow, TextField, - Tooltip, Typography, } from '@wso2/oxygen-ui'; -import { Edit, Plus, Search, Settings, Star, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { Edit, Plus, Search, Settings, Trash2 } from '@wso2/oxygen-ui-icons-react'; import GatewaySettingsDrawer from './components/GatewaySettingsDrawer'; import { gatewayTypeLabel } from './utils/gateway'; import NoGatewaysImage from './assets/images/NoGW.svg'; @@ -58,8 +57,6 @@ export type GatewaysListProps = { onEditClick: (gatewayId: string) => void; /** Returning a promise lets the confirm dialog stay open, and busy, until the delete settles. */ onDelete: (gatewayId: string, name: string) => void | Promise; - /** Hand the environment's default for this gateway's type over to it. */ - onMarkDefault: (gatewayId: string, name: string) => void | Promise; }; function truncateText(text: string, maxLength: number): string { @@ -73,23 +70,11 @@ const GatewaysList: FC = ({ onAddClick, onEditClick, onDelete, - onMarkDefault, }) => { const [searchQuery, setSearchQuery] = useState(''); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); const [deleting, setDeleting] = useState(false); const [settingsGateway, setSettingsGateway] = useState(null); - const [markingDefaultId, setMarkingDefaultId] = useState(null); - - const handleMarkDefault = async (gateway: Gateway) => { - if (markingDefaultId) return; - setMarkingDefaultId(gateway.id); - try { - await onMarkDefault(gateway.id, gateway.name); - } finally { - setMarkingDefaultId(null); - } - }; // Environments are keyed by name, so a gateway that points at an environment // missing from the list (deleted, or not yet loaded) still shows its raw @@ -256,31 +241,6 @@ const GatewaysList: FC = ({ - {/* Only offered for a gateway that is not already - the default: a default is handed over by - marking another, never cleared. */} - {gateway.isDefault ? null : ( - - - void handleMarkDefault(gateway)} - aria-label={`Mark ${gateway.name} as the default gateway`} - > - {markingDefaultId === gateway.id ? ( - - ) : ( - - )} - - - - )} onEditClick(gateway.id)} aria-label={`Edit ${gateway.name}`}> 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 fc8d174f78..da5db634b0 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/gatewaysApi.ts +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/gatewaysApi.ts @@ -102,20 +102,6 @@ export function createGatewaysClient(apiFetch: ApiFetch) { ...(input.isDefault ? { isDefault: true } : {}), }); }, - /** - * Hands the environment's default for this gateway's type over to it. - * - * The update endpoint replaces the mutable fields rather than patching them, - * so the gateway's current name and description are resent alongside the - * marking: a body carrying only `isDefault` would blank the description. - */ - async markGatewayDefault(gateway: Gateway): Promise { - await apiFetch('PUT', `/managed-gateways/${encodeURIComponent(gateway.id)}`, { - displayName: gateway.name, - description: gateway.description, - isDefault: true, - }); - }, async deleteGateway(id: string): Promise { await apiFetch('DELETE', `/managed-gateways/${encodeURIComponent(id)}`); }, From df1944407a760d6ff50a2ef3783365aae0c36929 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Thu, 10 Sep 2026 18:33:35 +0530 Subject: [PATCH 03/10] Derive the default switch from the chosen environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch now sits after the environment picker, because what it should say depends on which environment is chosen, and it ticks itself when the gateway would be the first of its type there — which is when the backend makes it the default whether or not it was asked to. Otherwise it stays off. It is disabled until an environment is selected, since before that there is nothing for the gateway to be the first of. A manual toggle afterwards sticks, and is only re-derived when the environment or type changes. Co-Authored-By: Claude Opus 4.8 --- .../src/GatewayForm.tsx | 58 ++++++++++++++----- .../src/GatewaysFeature.tsx | 1 + 2 files changed, 45 insertions(+), 14 deletions(-) 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 1be68f4356..8ffbdb51c8 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewayForm.tsx @@ -16,7 +16,7 @@ * under the License. */ -import { useState, type FC } from 'react'; +import { useEffect, useState, type FC } from 'react'; import { Box, Button, @@ -46,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; @@ -59,6 +65,7 @@ const GatewayForm: FC = ({ gateway, types, environments, + gateways, onBack, onSubmit, }) => { @@ -90,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; @@ -166,19 +193,6 @@ const GatewayForm: FC = ({ - - setIsDefault(event.target.checked)} - /> - } - label="Default gateway for this environment" - /> - - Environment @@ -191,6 +205,22 @@ 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 a6aa4a8296..338db2dc1a 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx @@ -222,6 +222,7 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { mode={view} gateway={editingGateway} types={gatewayTypes} + gateways={visibleGateways} environments={environments} onBack={() => { setView('list'); From 5b2b69d72faf8a735ac824701cf042dd612fb86a Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Thu, 10 Sep 2026 23:26:56 +0530 Subject: [PATCH 04/10] Deploy to several gateways at once from the deploy dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An environment runs a single build of an API at a time, so the deploy dialog now selects gateways rather than one gateway, and sends them in a single call that puts the same build on all of them. Each selected gateway gets its own endpoint field, because two gateways of one environment can serve different backends and a deployment owns its parameters. Gateways the API is already deployed on are ticked and locked with the reason given: the backend refuses a deploy that drops one, so offering to untick it would only produce an error — undeploying is how you stop deploying to a gateway. Retrying a single failed gateway still sends only that gateway, since it ships the build its peers already run and so leaves the environment on one build. Co-Authored-By: Claude Opus 4.8 --- .../src/DeployFeature.tsx | 14 +- .../apip-cloud-ui-deploy/src/DeployPage.tsx | 16 +- .../src/components/DeployDialog.tsx | 270 ++++++++++++------ .../apip-cloud-ui-deploy/src/deployApi.ts | 18 +- 4 files changed, 211 insertions(+), 107 deletions(-) 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..100a3e24ba 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, }), @@ -192,10 +190,12 @@ 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, }), 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..de1097d6f2 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx @@ -32,11 +32,14 @@ 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; @@ -74,8 +77,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); }; 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..08200adb04 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,18 @@ import { Alert, Box, Button, + Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, + FormControlLabel, FormLabel, MenuItem, Select, TextField, + Tooltip, Typography, } from '@wso2/oxygen-ui'; import StatusDot from './StatusDot'; @@ -51,7 +54,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 +97,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 +120,45 @@ 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 already deployed 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. + const alreadyDeployed = environment.gateways.filter((gateway) => !!gateway.deploymentId); + 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,72 +173,147 @@ 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} + + ); + })} + )} @@ -241,20 +343,6 @@ const DeployDialog: FC = ({ )} - - 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/deployApi.ts b/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts index f7283e011e..a1d364e43a 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts @@ -134,19 +134,29 @@ export function createDeployClient(apiFetch: ApiFetch, projectHandle: string, ap * Supplying `buildId` deploys that existing build. `fromEnvironment` promotes * instead, carrying that environment's build forward untouched. */ + /** + * 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. + * + * 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 } } : {}), }); }, From 55ca0dfa8ff89cc5997b42e80a5acdc75258be06 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Fri, 11 Sep 2026 00:29:25 +0530 Subject: [PATCH 05/10] Stop treating a stopped gateway as one that must be deployed to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy dialog decided a gateway was already deployed by testing for a deployment id. A stopped gateway keeps that id — it is how the deployment is identified — so undeploying a gateway left it ticked and locked, and there was no way to deploy or promote without it. Dropping it from the set is the whole point of stopping it. Locking now keys on the live status instead. Putting a stopped gateway back is also a deploy now rather than a revival of what it used to run: reviving its old deployment would put that build back while the rest of the environment had moved on. It deploys the build the environment is running, and says so when there is none to join. Co-Authored-By: Claude Opus 4.8 --- .../src/DeployFeature.tsx | 34 +++++++++++++++---- .../src/components/DeployDialog.tsx | 16 ++++++--- .../apip-cloud-ui-deploy/src/deployApi.ts | 7 ---- 3 files changed, 39 insertions(+), 18 deletions(-) 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 100a3e24ba..55399cc8ad 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx @@ -164,17 +164,37 @@ const DeployFeature: FC = ({ port }) => { }; /** - * 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. + * Puts a stopped gateway back by DEPLOYING to it, not by reviving what it used + * to run. There is no per-gateway redeploy: restoring its old deployment would + * put that old build back while the rest of the environment had moved on, which + * is the split the one-build-per-environment rule exists to prevent. + * + * So it deploys the build the environment is currently running. With nothing + * else deployed there, there is no build to join and the user is sent to the + * dialog to choose one instead. */ const handleRedeploy = (environment: Environment, gatewayId: string) => { const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId); - if (!client || !gateway?.deploymentId) return; + if (!client || !gateway) return; + const liveBuild = environment.gateways.find( + (candidate) => candidate.id !== gatewayId && candidate.status === 'DEPLOYED' + )?.buildId; + if (!liveBuild) { + notify( + `Nothing else is deployed in ${environment.name}, so there is no build to join. Use Deploy to choose one.`, + 'info' + ); + return; + } void runAction( - () => client.redeploy(environment.name, gatewayId, gateway.deploymentId!), - `Redeploying ${gateway.name}.`, - `Unable to redeploy ${gateway.name}.` + () => + client.deploy({ + environment: environment.name, + gateways: [{ gatewayId, endpointUrl: gateway.endpointUrl }], + buildId: liveBuild, + }), + `Deploying ${gateway.name}.`, + `Unable to deploy ${gateway.name}.` ); }; 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 08200adb04..eb623b28c7 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 @@ -120,10 +120,18 @@ const DeployDialog: FC = ({ ) : builds; const selectedBuildId = buildId || initialBuildId || availableBuilds[0]?.buildId || ''; - // A gateway the API is already deployed 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. - const alreadyDeployed = environment.gateways.filter((gateway) => !!gateway.deploymentId); + // 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 diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts b/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts index a1d364e43a..4ee0cea177 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts @@ -165,13 +165,6 @@ export function createDeployClient(apiFetch: ApiFetch, projectHandle: string, ap * 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 { From 69ddc4e9e3c3e25ba06121b62fdccc47ddd753e4 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Fri, 11 Sep 2026 00:33:53 +0530 Subject: [PATCH 06/10] Show the environment's build, drop redeploy, and settle the default switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build is a property of the environment now — every gateway in it runs the same one — so the environment card states it once instead of leaving it to be read off the gateway rows and compared. More than one build showing means the environment is split, which the deploy rules exist to prevent, so that is called out rather than quietly listed. Removes the per-gateway Redeploy button: putting a stopped gateway back is a deploy, and it goes through the dialog so it joins the build the environment is on. Stop is the only action on a serving gateway, and a stopped one offers none. Promoting no longer shows a build dropdown. The source environment runs one build and promotion carries that one forward, so the dropdown implied a choice the pipeline does not offer; the build is stated instead. The first gateway of a type in an environment also can no longer be unmarked as that environment's default: it becomes the default whatever the switch says, so offering to untick it was a lie. The Default badge is smaller, matching the sizing the pipeline cards already use. Co-Authored-By: Claude Opus 4.8 --- .../src/DeployFeature.tsx | 36 ----------- .../apip-cloud-ui-deploy/src/DeployPage.tsx | 3 - .../src/components/DeployDialog.tsx | 63 +++++++++++++------ .../src/components/EnvironmentCard.tsx | 53 +++++++++++++++- .../src/components/GatewayRow.tsx | 21 ++++--- .../src/GatewayForm.tsx | 12 ++-- .../src/GatewaysList.tsx | 8 ++- 7 files changed, 120 insertions(+), 76 deletions(-) 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 55399cc8ad..061200ff5e 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx @@ -163,41 +163,6 @@ const DeployFeature: FC = ({ port }) => { ); }; - /** - * Puts a stopped gateway back by DEPLOYING to it, not by reviving what it used - * to run. There is no per-gateway redeploy: restoring its old deployment would - * put that old build back while the rest of the environment had moved on, which - * is the split the one-build-per-environment rule exists to prevent. - * - * So it deploys the build the environment is currently running. With nothing - * else deployed there, there is no build to join and the user is sent to the - * dialog to choose one instead. - */ - const handleRedeploy = (environment: Environment, gatewayId: string) => { - const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId); - if (!client || !gateway) return; - const liveBuild = environment.gateways.find( - (candidate) => candidate.id !== gatewayId && candidate.status === 'DEPLOYED' - )?.buildId; - if (!liveBuild) { - notify( - `Nothing else is deployed in ${environment.name}, so there is no build to join. Use Deploy to choose one.`, - 'info' - ); - return; - } - void runAction( - () => - client.deploy({ - environment: environment.name, - gateways: [{ gatewayId, endpointUrl: gateway.endpointUrl }], - buildId: liveBuild, - }), - `Deploying ${gateway.name}.`, - `Unable to deploy ${gateway.name}.` - ); - }; - /** * Retrying sends the gateway the build it already has, not a new one: a failed * deployment is retried as it was, so a retry never quietly ships something @@ -286,7 +251,6 @@ const DeployFeature: FC = ({ port }) => { onDeploy={handleDeploy} onStopGateway={handleStop} onRetryGateway={handleRetry} - onRedeployGateway={handleRedeploy} /> ); }; 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 de1097d6f2..eb3192f7e2 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx @@ -45,7 +45,6 @@ export type DeployPageProps = { ) => void; onStopGateway: (environment: Environment, gatewayId: string) => void; onRetryGateway: (environment: Environment, gatewayId: string) => void; - onRedeployGateway: (environment: Environment, gatewayId: string) => void; }; /** @@ -69,7 +68,6 @@ const DeployPage: FC = ({ onDeploy, onStopGateway, onRetryGateway, - onRedeployGateway, }) => { const [dialog, setDialog] = useState(null); @@ -176,7 +174,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/DeployDialog.tsx b/portals/cloud-plugins/apip-cloud-ui-deploy/src/components/DeployDialog.tsx index eb623b28c7..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 @@ -22,6 +22,7 @@ import { Box, Button, Checkbox, + Chip, Dialog, DialogActions, DialogContent, @@ -328,26 +329,50 @@ const DeployDialog: FC = ({ {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} + + )) + )} + + + )} )} 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..09541f44aa 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,12 +47,22 @@ 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; + // 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(); + const promoteDisabledReason = nextEnvironment && !canPromote ? `All gateways in ${nextEnvironment.name} are inactive. Activate a gateway before promoting.` @@ -77,6 +86,45 @@ const EnvironmentCard: FC = ({ + {/* The build belongs to the ENVIRONMENT now, not to each gateway: they all + run the same one. So it is stated once, here, rather than being left to + be read off the rows and compared. + + 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. */} + + Build + {runningBuilds.length === 0 ? ( + + Nothing deployed + + ) : runningBuilds.length === 1 ? ( + + ) : ( + <> + {runningBuilds.map((buildId) => ( + + ))} + + Split across builds + + + )} + + @@ -97,7 +145,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..a39af840aa 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 @@ -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 ( 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 5a83a65c15..137224ce5c 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx @@ -206,7 +206,13 @@ const GatewaysList: FC = ({ gateways in one environment can each be marked — one per type. */} {gateway.isDefault ? ( - + ) : null} From 901bf1dd3253936938a4ad54629be1335fb5a2fa Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Fri, 11 Sep 2026 00:49:35 +0530 Subject: [PATCH 07/10] Say retrying, not redeploying, on the retry action Redeploy is no longer an operation, so a retry reporting "Redeploying" named something that does not exist any more. Co-Authored-By: Claude Opus 4.8 --- .../cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 061200ff5e..928c69a655 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx @@ -184,8 +184,8 @@ const DeployFeature: FC = ({ port }) => { 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}.` ); }; From 5ff0aec2b0383cf1558afccfe5ed60e559c969ee Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Fri, 11 Sep 2026 01:00:43 +0530 Subject: [PATCH 08/10] Put the environment's build beside its name and gate promotion on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build sits on the right of the card header now, using the space the header was leaving empty, rather than on a row of its own below the name. Promotion is also gated on the environment having a build to carry forward. Once every gateway here is stopped the environment runs nothing, and the backend already refuses the promotion for that reason — the button was still offering it, so it offered an action that could only fail. The tooltip now says which of the two reasons applies: nothing deployed here, or no active gateway there. Co-Authored-By: Claude Opus 4.8 --- .../src/components/EnvironmentCard.tsx | 104 ++++++++++-------- 1 file changed, 59 insertions(+), 45 deletions(-) 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 09541f44aa..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 @@ -51,7 +51,6 @@ const EnvironmentCard: FC = ({ const { gateways } = environment; const activeCount = activeGatewayCount(gateways); const deployed = hasAnyDeployment(gateways); - const canPromote = !!nextEnvironment && activeGatewayCount(nextEnvironment.gateways) > 0; // 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. @@ -63,10 +62,21 @@ const EnvironmentCard: FC = ({ ) ).sort(); - const promoteDisabledReason = - nextEnvironment && !canPromote - ? `All gateways in ${nextEnvironment.name} are inactive. Activate a gateway before promoting.` - : ''; + // 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 - - - - {/* The build belongs to the ENVIRONMENT now, not to each gateway: they all - run the same one. So it is stated once, here, rather than being left to - be read off the rows and compared. + {/* 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 + 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. */} - - Build - {runningBuilds.length === 0 ? ( - - Nothing deployed + + + {environment.name} + + {activeCount} of {gateways.length} gateway{gateways.length === 1 ? '' : 's'} active - ) : runningBuilds.length === 1 ? ( - - ) : ( - <> - {runningBuilds.map((buildId) => ( - - ))} - - Split across builds + + + Build + {runningBuilds.length === 0 ? ( + + Nothing deployed - - )} + ) : runningBuilds.length === 1 ? ( + + ) : ( + + + {runningBuilds.map((buildId) => ( + + ))} + + + Split across builds + + + )} + From df2491aa3f58875270f5053847f29e002ea02339 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Fri, 11 Sep 2026 12:40:07 +0530 Subject: [PATCH 09/10] Let a build be deleted from the build area Co-Authored-By: Claude Opus 5 (1M context) --- .../src/DeployFeature.tsx | 16 +++ .../apip-cloud-ui-deploy/src/DeployPage.tsx | 31 +++++ .../src/components/BuildAreaCard.tsx | 121 ++++++++++++++---- .../apip-cloud-ui-deploy/src/deployApi.ts | 39 ++++-- .../apip-cloud-ui-deploy/src/types.ts | 2 + 5 files changed, 171 insertions(+), 38 deletions(-) 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 928c69a655..b4512cf5a6 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx @@ -153,6 +153,21 @@ const DeployFeature: FC = ({ port }) => { ); }; + /** + * 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.deleteBuild(buildId), + `Deleted build ${buildId}.`, + `Unable to delete build ${buildId}.` + ); + }; + const handleStop = (environment: Environment, gatewayId: string) => { const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId); if (!client || !gateway?.deploymentId) return; @@ -251,6 +266,7 @@ const DeployFeature: FC = ({ port }) => { onDeploy={handleDeploy} onStopGateway={handleStop} onRetryGateway={handleRetry} + 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 eb3192f7e2..e02d695b19 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx @@ -45,6 +45,34 @@ export type DeployPageProps = { ) => void; onStopGateway: (environment: Environment, gatewayId: string) => void; onRetryGateway: (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; }; /** @@ -68,6 +96,7 @@ const DeployPage: FC = ({ onDeploy, onStopGateway, onRetryGateway, + onDeleteBuild, }) => { const [dialog, setDialog] = useState(null); @@ -156,10 +185,12 @@ const DeployPage: FC = ({ setDialog({ targetIndex: 0, buildId, createBuild }) } + onDeleteBuild={onDeleteBuild} /> {environments.map((environment, index) => ( 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..ab4c33c7bb 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,86 @@ 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? Deployments that ran it stay on their gateways but can no + longer be promoted onward. + + + + + + + ) : null} + + + ); + })} ); diff --git a/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts b/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts index 4ee0cea177..f910cc0444 100644 --- a/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts +++ b/portals/cloud-plugins/apip-cloud-ui-deploy/src/deployApi.ts @@ -36,7 +36,12 @@ type StageDTO = { gateways?: GatewayDeploymentDTO[]; }; -type BuildDTO = { buildId: string; createdBy?: string; createdAt?: string }; +type BuildDTO = { + buildId: string; + description?: string; + createdBy?: string; + createdAt?: string; +}; /** * The gateways resource, which owns a gateway's identity and health. The @@ -112,11 +117,30 @@ export function createDeployClient(apiFetch: ApiFetch, projectHandle: string, ap const response = await apiFetch<{ list?: BuildDTO[] }>('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. @@ -127,13 +151,6 @@ export function createDeployClient(apiFetch: ApiFetch, projectHandle: string, ap return api?.upstream?.main?.url; }, - /** - * Deploys to one gateway of an environment with the endpoint it should serve. - * - * 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. - */ /** * 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 @@ -160,12 +177,6 @@ export function createDeployClient(apiFetch: ApiFetch, projectHandle: string, ap }); }, - /** - * 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. - */ - /** 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; }; From dd177d7b530441ed542c05004252a70933c85694 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Fri, 11 Sep 2026 13:09:56 +0530 Subject: [PATCH 10/10] Show when a gateway was deployed instead of a build id card Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/BuildAreaCard.tsx | 3 +- .../src/components/GatewayRow.tsx | 36 ++++++++----------- 2 files changed, 16 insertions(+), 23 deletions(-) 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 ab4c33c7bb..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 @@ -157,8 +157,7 @@ const BuildAreaCard: FC = ({ {confirming ? ( - Delete this build? Deployments that ran it stay on their gateways but can no - longer be promoted onward. + Delete this build?