Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 25 additions & 25 deletions portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployFeature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,18 +135,16 @@ const DeployFeature: FC<DeployFeatureProps> = ({ 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,
}),
Expand All @@ -155,28 +153,28 @@ const DeployFeature: FC<DeployFeatureProps> = ({ port }) => {
);
};

const handleStop = (environment: Environment, gatewayId: string) => {
const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId);
if (!client || !gateway?.deploymentId) return;
/**
* Deleting a build is how a slot is freed once the API is at its limit and
* deploying is refused for it. The platform is the authority on whether a build
* can go — a gateway may have claimed it since the page last loaded — so a
* refusal surfaces as it comes back rather than being predicted here.
*/
const handleDeleteBuild = (buildId: string) => {
if (!client) return;
void runAction(
() => client.undeploy(environment.name, gatewayId, gateway.deploymentId!),
`Stopping ${gateway.name}.`,
`Unable to stop ${gateway.name}.`
() => client.deleteBuild(buildId),
`Deleted build ${buildId}.`,
`Unable to delete build ${buildId}.`
);
};

/**
* Puts a suspended deployment back on its gateway. The deployment is immutable,
* so this restores exactly what was running — same build, same endpoint — and
* builds nothing, which is what separates it from a retry.
*/
const handleRedeploy = (environment: Environment, gatewayId: string) => {
const handleStop = (environment: Environment, gatewayId: string) => {
const gateway = environment.gateways.find((candidate) => candidate.id === gatewayId);
if (!client || !gateway?.deploymentId) return;
void runAction(
() => client.redeploy(environment.name, gatewayId, gateway.deploymentId!),
`Redeploying ${gateway.name}.`,
`Unable to redeploy ${gateway.name}.`
() => client.undeploy(environment.name, gatewayId, gateway.deploymentId!),
`Stopping ${gateway.name}.`,
`Unable to stop ${gateway.name}.`
);
};

Expand All @@ -192,15 +190,17 @@ const DeployFeature: FC<DeployFeatureProps> = ({ port }) => {
const index = environments.findIndex((candidate) => candidate.name === environment.name);
void runAction(
() =>
// Only this gateway: the retry ships the build its peers are already
// running, so the environment stays on one build and the backend does not
// require them to be redeployed alongside it.
client.deploy({
environment: environment.name,
gatewayId,
endpointUrl: gateway.endpointUrl,
gateways: [{ gatewayId, endpointUrl: gateway.endpointUrl }],
buildId: gateway.buildId,
fromEnvironment: index > 0 ? environments[index - 1]?.name : undefined,
}),
`Redeploying ${gateway.name}.`,
`Unable to redeploy ${gateway.name}.`
`Retrying ${gateway.name}.`,
`Unable to retry ${gateway.name}.`
);
};

Expand Down Expand Up @@ -266,7 +266,7 @@ const DeployFeature: FC<DeployFeatureProps> = ({ port }) => {
onDeploy={handleDeploy}
onStopGateway={handleStop}
onRetryGateway={handleRetry}
onRedeployGateway={handleRedeploy}
onDeleteBuild={handleDeleteBuild}
/>
);
};
Expand Down
50 changes: 42 additions & 8 deletions portals/cloud-plugins/apip-cloud-ui-deploy/src/DeployPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,47 @@ export type DeployPageProps = {
/** The backend URL the API is defined against; the deploy form starts from it. */
apiEndpointUrl?: string;
busy: boolean;
/** Deploys to `target`; `from` is set when this is a promotion. */
/**
* Deploys to `target`; `from` is set when this is a promotion. Every gateway
* goes in one call with its own endpoint, because an environment runs a single
* build of an API at a time.
*/
onDeploy: (
target: Environment,
gatewayId: string,
endpointUrl: string,
gateways: { gatewayId: string; endpointUrl?: string }[],
from?: Environment,
buildId?: string
) => void;
onStopGateway: (environment: Environment, gatewayId: string) => void;
onRetryGateway: (environment: Environment, gatewayId: string) => void;
onRedeployGateway: (environment: Environment, gatewayId: string) => void;
/** Deletes a build, freeing a slot when the API is at its build limit. */
onDeleteBuild: (buildId: string) => void;
};

/**
* The statuses that mean a gateway is holding a build — on it, going on, or coming
* off. The platform refuses to delete a build in any of them, so the page says so
* up front instead of offering the action and having it rejected. Suspended and
* failed deployments are deliberately absent: their builds ARE deletable, and they
* are the ones automatic cleanup will not reclaim.
*/
const GATEWAY_HELD_STATUSES = ['DEPLOYED', 'DEPLOYING', 'UNDEPLOYING'];

/**
* Why each build cannot be deleted, by build id, naming the environment that is
* holding it so the reason is actionable rather than just a refusal.
*/
const undeletableBuildReasons = (environments: Environment[]): Record<string, string> => {
const reasons: Record<string, string> = {};
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;
};

/**
Expand All @@ -66,16 +96,19 @@ const DeployPage: FC<DeployPageProps> = ({
onDeploy,
onStopGateway,
onRetryGateway,
onRedeployGateway,
onDeleteBuild,
}) => {
const [dialog, setDialog] = useState<DialogState>(null);

const target = dialog ? (environments[dialog.targetIndex] ?? null) : null;
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);
};

Expand Down Expand Up @@ -152,10 +185,12 @@ const DeployPage: FC<DeployPageProps> = ({
<BuildAreaCard
builds={builds}
targetEnvironment={environments[0]}
undeletableBuilds={undeletableBuildReasons(environments)}
busy={busy}
onDeployClick={(buildId, createBuild) =>
setDialog({ targetIndex: 0, buildId, createBuild })
}
onDeleteBuild={onDeleteBuild}
/>

{environments.map((environment, index) => (
Expand All @@ -170,7 +205,6 @@ const DeployPage: FC<DeployPageProps> = ({
}
onStopGateway={(gatewayId) => onStopGateway(environment, gatewayId)}
onRetryGateway={(gatewayId) => onRetryGateway(environment, gatewayId)}
onRedeployGateway={(gatewayId) => onRedeployGateway(environment, gatewayId)}
/>
</Fragment>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, string>;
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;
Expand All @@ -55,12 +66,18 @@ type DeployAction = 'deploy' | 'build-and-deploy';
const BuildAreaCard: FC<BuildAreaCardProps> = ({
builds,
targetEnvironment,
undeletableBuilds,
busy,
onDeployClick,
onDeleteBuild,
}) => {
const [deployMenuAnchor, setDeployMenuAnchor] = useState<HTMLElement | null>(null);
const [buildsDrawerOpen, setBuildsDrawerOpen] = useState(false);
const [selectedDeployAction, setSelectedDeployAction] = useState<DeployAction>('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<string | null>(null);
const latestBuild = builds[0] ?? null;
const visibleBuilds = builds.slice(0, VISIBLE_BUILD_COUNT);
const canDeploy = activeGatewayCount(targetEnvironment.gateways) > 0;
Expand All @@ -86,30 +103,85 @@ const BuildAreaCard: FC<BuildAreaCardProps> = ({

const renderBuilds = (items: Build[]) => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items.map((build) => (
<Card key={build.buildId} variant="outlined">
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
<Typography
variant="body2"
sx={{
color: 'text.primary',
fontWeight: 700,
fontSize: 14,
}}
>
{build.buildId}
</Typography>
{build.createdAt ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Clock size={13} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 12 }}>
{relativeTime(build.createdAt)}
</Typography>
{items.map((build) => {
const blockedReason = undeletableBuilds[build.buildId];
const confirming = pendingDelete === build.buildId;
return (
<Card key={build.buildId} variant="outlined">
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
color: 'text.primary',
fontWeight: 700,
fontSize: 14,
}}
>
{build.buildId}
</Typography>
{build.description ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.25, fontSize: 12 }}
>
{build.description}
</Typography>
) : null}
{build.createdAt ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Clock size={13} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 12 }}>
{relativeTime(build.createdAt)}
</Typography>
</Box>
) : null}
</Box>
{confirming ? null : (
<Tooltip title={blockedReason || 'Delete this build'}>
<span>
<IconButton
size="small"
aria-label={`Delete build ${build.buildId}`}
disabled={busy || Boolean(blockedReason)}
onClick={() => setPendingDelete(build.buildId)}
>
<Trash2 size={15} />
</IconButton>
</span>
</Tooltip>
)}
</Box>
) : null}
</CardContent>
</Card>
))}
{confirming ? (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 12 }}>
Delete this build?
</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Button
size="small"
color="error"
variant="contained"
disabled={busy}
onClick={() => {
setPendingDelete(null);
onDeleteBuild(build.buildId);
}}
>
Delete
</Button>
<Button size="small" disabled={busy} onClick={() => setPendingDelete(null)}>
Cancel
</Button>
</Box>
</Box>
) : null}
</CardContent>
</Card>
);
})}
</Box>
);

Expand Down
Loading
Loading