From 12dab080a321ed8529d9b1c6028e054956f815cd Mon Sep 17 00:00:00 2001 From: thivindu Date: Wed, 5 Aug 2026 13:49:35 +0530 Subject: [PATCH 1/2] Improve error handling of Dp origin artifact deletion --- .../externalServers/ExternalServersList.tsx | 7 +++++ .../appShellPages/proxies/LLMProxiesList.tsx | 9 ++++++ .../proxies/LLMProxyOverview.tsx | 7 +++++ .../serviceProvider/ProvidersList.tsx | 28 +++++++++++++++---- .../ServiceProviderOverview.tsx | 15 ++++++++-- .../src/utils/readOnlyArtifacts.tsx | 25 ++++++++++++++++- 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersList.tsx index cad5643791..6773fa80d3 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersList.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersList.tsx @@ -63,6 +63,7 @@ import { mcpProxiesApis } from '../../../../apis/MCP/mcpProxiesApis'; import type { MCPServer } from '../../../../utils/types'; import NoMCPServers from '../../../../assets/images/NoMCPServers.svg'; import { getErrorMessage } from '../../../../utils/apiError'; +import { GatewayArtifactDeleteWarning } from '../../../../utils/readOnlyArtifacts'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; import { DISABLED_ACTION_SX, NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; @@ -594,6 +595,12 @@ export default function ExternalServersList(): React.JSX.Element { > Delete external server + {deleteTarget?.readOnly ? ( + + ) : null} Are you sure you want to delete {deleteTarget?.displayName}? diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxiesList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxiesList.tsx index 3d89d73ab0..a8cb6be7ba 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxiesList.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxiesList.tsx @@ -64,6 +64,7 @@ import NoProxies from '../../../../assets/images/NoProxies.svg'; import ErrorAlert from '../../../../Components/common/ErrorAlert'; import { useAIWorkspaceSnackbar } from '../../../../hooks/aiWorkspaceSnackbar'; import { getErrorMessage } from '../../../../utils/apiError'; +import { GatewayArtifactDeleteWarning } from '../../../../utils/readOnlyArtifacts'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; @@ -118,6 +119,7 @@ export default function LLMProxiesList() { const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string; + readOnly: boolean; } | null>(null); useEffect(() => { @@ -542,6 +544,7 @@ export default function LLMProxiesList() { setDeleteTarget({ id: proxy.id, name: proxy.displayName, + readOnly: Boolean(proxy.readOnly), }); }} aria-label={`Delete ${proxy.displayName}`} @@ -569,6 +572,12 @@ export default function LLMProxiesList() { > Delete App LLM Proxy + {deleteTarget?.readOnly ? ( + + ) : null} Are you sure you want to delete {deleteTarget?.name}? diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx index a1d7fa5da3..cd386e0809 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx @@ -77,6 +77,7 @@ import type { } from '../../../../utils/types'; import { getErrorMessage } from '../../../../utils/apiError'; import { + GatewayArtifactDeleteWarning, GatewayArtifactReadOnlyBanner, } from '../../../../utils/readOnlyArtifacts'; @@ -597,6 +598,12 @@ function ProxyOverviewContent() { > Delete App LLM Proxy + {isReadOnlyProxy ? ( + + ) : null} Are you sure you want to delete {proxy.displayName}? This action cannot be undone. diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ProvidersList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ProvidersList.tsx index 672b98ca31..98e26a0f20 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ProvidersList.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ProvidersList.tsx @@ -65,6 +65,8 @@ import { useProviderTemplates } from '../../../../contexts/llmProvider/providerT import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; import * as llmProviderApis from '../../../../apis/llmProviderApis'; import { PLATFORM_API_BASE_URL } from '../../../../paths'; +import { getErrorMessage } from '../../../../utils/apiError'; +import { GatewayArtifactDeleteWarning } from '../../../../utils/readOnlyArtifacts'; import AnthropicLogo from '../../../../assets/brands/Anthropic.jpg'; import AWSBedrockLogo from '../../../../assets/brands/AWSBedrock.webp'; @@ -136,6 +138,7 @@ export default function ServiceProviders() { const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string; + readOnly: boolean; } | null>(null); const [deleteConfirmationInput, setDeleteConfirmationInput] = useState(''); const [assignTarget, setAssignTarget] = useState<{ @@ -182,14 +185,18 @@ export default function ServiceProviders() { showSnackbar('Provider deleted successfully.', 'success'); setDeleteTarget(null); setDeleteConfirmationInput(''); - } catch { - showSnackbar('Failed to delete provider. Please try again.', 'error'); + } catch (error) { + showSnackbar( + getErrorMessage(error, 'Failed to delete provider. Please try again.'), + 'error' + ); } }; const checkProviderUsageAndConfirmDelete = async ( providerId: string, - providerName: string + providerName: string, + isReadOnlyProvider: boolean ) => { if (!currentOrganization?.uuid) { showSnackbar( @@ -217,7 +224,11 @@ export default function ServiceProviders() { return; } - setDeleteTarget({ id: providerId, name: providerName }); + setDeleteTarget({ + id: providerId, + name: providerName, + readOnly: isReadOnlyProvider, + }); setDeleteConfirmationInput(''); } catch { showSnackbar( @@ -779,7 +790,8 @@ export default function ServiceProviders() { event.stopPropagation(); void checkProviderUsageAndConfirmDelete( providerId, - provider.displayName + provider.displayName, + Boolean(provider.readOnly) ); }} aria-label={`Delete ${providerDisplayName}`} @@ -811,6 +823,12 @@ export default function ServiceProviders() { '{deleteTarget?.name ?? ''}'? + {deleteTarget?.readOnly ? ( + + ) : null} This action will be irreversible and all related details will be lost. Please type in the component name below to confirm. diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx index 1dbd396048..f54ebe60d4 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx @@ -103,8 +103,10 @@ import { } from '../../../../contexts/AIEntitiesContext'; import { DisabledActionTooltip, + GatewayArtifactDeleteWarning, GatewayArtifactReadOnlyBanner, } from '../../../../utils/readOnlyArtifacts'; +import { getErrorMessage } from '../../../../utils/apiError'; import AnthropicLogo from '../../../../assets/brands/Anthropic.jpg'; import AWSBedrockLogo from '../../../../assets/brands/AWSBedrock.webp'; @@ -559,8 +561,11 @@ function ServiceProviderOverviewContent() { setDeleteTarget(null); setDeleteConfirmationInput(''); navigate(providersPath, { replace: true }); - } catch { - showSnackbar('Failed to delete provider. Please try again.', 'error'); + } catch (error) { + showSnackbar( + getErrorMessage(error, 'Failed to delete provider. Please try again.'), + 'error' + ); } finally { setIsDeletingProvider(false); } @@ -1048,6 +1053,12 @@ function ServiceProviderOverviewContent() { '{deleteTarget?.name ?? ''}'? + {isReadOnlyProvider ? ( + + ) : null} This action will be irreversible and all related details will be lost. Please type in the component name below to confirm. diff --git a/portals/ai-workspace/src/utils/readOnlyArtifacts.tsx b/portals/ai-workspace/src/utils/readOnlyArtifacts.tsx index b6fc4782be..04ced17138 100644 --- a/portals/ai-workspace/src/utils/readOnlyArtifacts.tsx +++ b/portals/ai-workspace/src/utils/readOnlyArtifacts.tsx @@ -17,7 +17,7 @@ */ import type { ReactNode } from 'react'; -import { Box, Card, Stack, Tooltip, Typography } from '@wso2/oxygen-ui'; +import { Alert, Box, Card, Stack, Tooltip, Typography } from '@wso2/oxygen-ui'; import { Lock } from '@wso2/oxygen-ui-icons-react'; export const GATEWAY_MANAGED_ARTIFACT_TOOLTIP = @@ -50,6 +50,29 @@ export function GatewayArtifactReadOnlyBanner({ ); } +/** + * Warning shown inside the delete confirmation dialog of a gateway-created + * (data-plane-originated) artifact. + * `artifactType` is the user-facing kind ("LLM Provider", "App LLM Proxy", + * "MCP Proxy"); `artifactName` is the artifact's display name. + */ +export function GatewayArtifactDeleteWarning({ + artifactType, + artifactName, +}: { + artifactType: string; + artifactName?: string; +}) { + return ( + + This {artifactType} was created from a gateway. Make sure you have + undeployed{' '} + {artifactName ? {artifactName} : `this ${artifactType}`}{' '} + from the gateways first. + + ); +} + type DisabledActionTooltipProps = { children: ReactNode; disabled: boolean; From 462c1082860e76def091b41559ad73880bb0f60d Mon Sep 17 00:00:00 2001 From: thivindu Date: Wed, 5 Aug 2026 21:00:40 +0530 Subject: [PATCH 2/2] Refactor artifact deletion in overview pages --- .../ExternalServersOverview.tsx | 140 +++++++++++++++++- .../proxies/LLMProxyOverview.tsx | 82 ++++++++-- .../ServiceProviderOverview.tsx | 110 ++++++++++++-- .../src/utils/artifactDeletion.ts | 81 ++++++++++ 4 files changed, 383 insertions(+), 30 deletions(-) create mode 100644 portals/ai-workspace/src/utils/artifactDeletion.ts diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx index 6b47707c22..5d0f6d91e2 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx @@ -31,6 +31,11 @@ import { Card, Chip, CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, Divider, FormControl, FormLabel, @@ -55,6 +60,7 @@ import { Edit, Eye, EyeOff, + Trash2, } from '@wso2/oxygen-ui-icons-react'; import { FormattedMessage } from 'react-intl'; import { useAppShell } from '../../../../contexts/AppShellContext'; @@ -94,8 +100,16 @@ import type { EndpointValidationResponse } from './externalServersValidationType import ExternalServerStepBanner from '../quickStart/ExternalServerStepBanner'; import type { ExternalServerStepBannerStepId } from '../quickStart/ExternalServerStepBanner'; import { + DisabledActionTooltip, + GatewayArtifactDeleteWarning, GatewayArtifactReadOnlyBanner, } from '../../../../utils/readOnlyArtifacts'; +import { + activeDeploymentDeleteBlockedReason, + countActiveDeployments, +} from '../../../../utils/artifactDeletion'; +import { useAppAuth } from '../../../../contexts/AppAuthContext'; +import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions'; function getInitials(name: string): string { const words = name.trim().split(/\s+/); @@ -226,6 +240,8 @@ export default function ExternalServersOverview(): JSX.Element { const navigate = useNavigate(); const showSnackbar = useAIWorkspaceSnackbar(); + const { hasPermission } = useAppAuth(); + const canDeleteMcpProxy = hasPermission(SCOPES.MCP_PROXY_DELETE); const [server, setServer] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSavingChanges, setIsSavingChanges] = useState(false); @@ -236,6 +252,9 @@ export default function ExternalServersOverview(): JSX.Element { const [selectedPolicies, setSelectedPolicies] = useState( [] ); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [activeDeploymentCount, setActiveDeploymentCount] = useState(null); const isReadOnlyServer = Boolean(server?.readOnly); // Backend Connection tab @@ -396,6 +415,61 @@ export default function ExternalServersOverview(): JSX.Element { }; }, [organizationId, serverId, apimBaseUrl]); + useEffect(() => { + if (!organizationId || !serverId || !isReadOnlyServer || !canDeleteMcpProxy) { + setActiveDeploymentCount(null); + return; + } + + let isCancelled = false; + + const resolveActiveDeployments = async () => { + const deployments = await getMCPServerDeployments(serverId, apimBaseUrl); + + if (isCancelled) return; + + setActiveDeploymentCount(countActiveDeployments(deployments.list)); + }; + + resolveActiveDeployments().catch((err) => { + logger.error( + 'Failed to resolve MCP Proxy deployments for delete guard:', + err + ); + if (isCancelled) return; + setActiveDeploymentCount(null); + }); + + return () => { + isCancelled = true; + }; + }, [organizationId, serverId, isReadOnlyServer, canDeleteMcpProxy, apimBaseUrl]); + + const deleteBlockedReason = useMemo(() => { + if (!isReadOnlyServer || activeDeploymentCount === null || activeDeploymentCount === 0) { + return null; + } + return activeDeploymentDeleteBlockedReason( + 'MCP Proxy', + activeDeploymentCount + ); + }, [isReadOnlyServer, activeDeploymentCount]); + + const handleDeleteConfirm = async () => { + if (!server || !serverId || isDeleting) return; + try { + setIsDeleting(true); + await mcpProxiesApis.deleteMCPServer(serverId, apimBaseUrl); + showSnackbar('MCP Proxy deleted successfully.', 'success'); + setIsDeleteDialogOpen(false); + navigate(listPath, { replace: true }); + } catch (err) { + showSnackbar(getErrorMessage(err, 'Failed to delete MCP Proxy.'), 'error'); + } finally { + setIsDeleting(false); + } + }; + const selectedGateway = useMemo( () => deployedGateways.find((gateway) => gateway.id === selectedGatewayId) ?? @@ -919,7 +993,7 @@ export default function ExternalServersOverview(): JSX.Element { {/* For gateway-created (read-only) proxies the deployments remain viewable (deploy/redeploy/restore/undeploy are disabled on the page itself), so @@ -1030,6 +1106,24 @@ export default function ExternalServersOverview(): JSX.Element { /> )} + + setIsDeleteDialogOpen(true)} + aria-label={`Delete ${server.displayName}`} + data-cyid="delete-mcp-proxy-button" + > + + + @@ -1321,6 +1415,46 @@ export default function ExternalServersOverview(): JSX.Element { + + { + if (isDeleting) return; + setIsDeleteDialogOpen(false); + }} + > + Delete MCP Proxy + + {isReadOnlyServer ? ( + + ) : null} + + Are you sure you want to delete{' '} + {server.displayName}? This action cannot be undone. + + + + + + + ); } diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx index cd386e0809..064e6ad99c 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx @@ -77,9 +77,17 @@ import type { } from '../../../../utils/types'; import { getErrorMessage } from '../../../../utils/apiError'; import { + activeDeploymentDeleteBlockedReason, + countActiveDeployments, +} from '../../../../utils/artifactDeletion'; +import { + DisabledActionTooltip, GatewayArtifactDeleteWarning, GatewayArtifactReadOnlyBanner, } from '../../../../utils/readOnlyArtifacts'; +import { getLLMProxyDeployments } from '../../../../apis/llmProxiesApis'; +import { PLATFORM_API_BASE_URL } from '../../../../paths'; +import { logger } from '../../../../utils/logger'; type TabPanelProps = { value: number; @@ -175,6 +183,53 @@ function ProxyOverviewContent() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const isReadOnlyProxy = Boolean(proxy?.readOnly); + const [activeDeploymentCount, setActiveDeploymentCount] = useState(null); + + useEffect(() => { + const organizationId = currentOrganization?.uuid; + const proxyId = proxy?.id; + if (!organizationId || !proxyId || !isReadOnlyProxy || !canDeleteProxy) { + setActiveDeploymentCount(null); + return; + } + + let isCancelled = false; + + const resolveActiveDeployments = async () => { + const deployments = await getLLMProxyDeployments( + proxyId, + organizationId, + PLATFORM_API_BASE_URL + ); + + if (isCancelled) return; + + setActiveDeploymentCount(countActiveDeployments(deployments.list)); + }; + + resolveActiveDeployments().catch((err) => { + logger.error( + 'Failed to resolve App LLM Proxy deployments for delete guard:', + err + ); + if (isCancelled) return; + setActiveDeploymentCount(null); + }); + + return () => { + isCancelled = true; + }; + }, [currentOrganization?.uuid, proxy?.id, isReadOnlyProxy, canDeleteProxy]); + + const deleteBlockedReason = useMemo(() => { + if (!isReadOnlyProxy || activeDeploymentCount === null || activeDeploymentCount === 0) { + return null; + } + return activeDeploymentDeleteBlockedReason( + 'App LLM Proxy', + activeDeploymentCount + ); + }, [isReadOnlyProxy, activeDeploymentCount]); const getProviderId = (providerValue?: LLMProxy['provider']): string => { if (!providerValue) return ''; @@ -476,20 +531,21 @@ function ProxyOverviewContent() { > {isReadOnlyProxy ? 'View Deployments' : 'Deploy to Gateway'} - - - setDeleteDialogOpen(true)} - aria-label="Delete proxy" - > - - - - + setDeleteDialogOpen(true)} + aria-label="Delete proxy" + > + + + diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx index f54ebe60d4..5847b9eb5b 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx @@ -107,6 +107,11 @@ import { GatewayArtifactReadOnlyBanner, } from '../../../../utils/readOnlyArtifacts'; import { getErrorMessage } from '../../../../utils/apiError'; +import { + activeDeploymentDeleteBlockedReason, + countActiveDeployments, + linkedProxiesDeleteBlockedReason, +} from '../../../../utils/artifactDeletion'; import AnthropicLogo from '../../../../assets/brands/Anthropic.jpg'; import AWSBedrockLogo from '../../../../assets/brands/AWSBedrock.webp'; @@ -300,6 +305,8 @@ function ServiceProviderOverviewContent() { const [checkingProviderId, setCheckingProviderId] = useState( null ); + const [activeDeploymentCount, setActiveDeploymentCount] = useState(null); + const [linkedProxyCount, setLinkedProxyCount] = useState(null); const showSnackbar = useAIWorkspaceSnackbar(); const hasUnsavedChanges = hasDraftChanges || isRateLimitingDirty; const selectedGateway = useMemo( @@ -492,6 +499,74 @@ function ServiceProviderOverviewContent() { const proxyQuotaTooltip = 'You cannot create more App LLM Proxies because your organization has reached the maximum limit of 5 proxies.'; const isReadOnlyProvider = Boolean(provider?.readOnly); + + useEffect(() => { + const organizationId = currentOrganization?.uuid; + const providerId = provider?.id; + if (!organizationId || !providerId || !canDelete) { + setActiveDeploymentCount(null); + setLinkedProxyCount(null); + return; + } + + let isCancelled = false; + + const resolveDeletePreconditions = async () => { + const [deploymentsResult, linkedProxiesResult] = await Promise.allSettled([ + isReadOnlyProvider + ? getLLMProviderDeployments(providerId, organizationId, apimBaseUrl) + : Promise.resolve({ list: [], count: 0 }), + getLLMProviderProxies(providerId, organizationId, apimBaseUrl), + ]); + + if (isCancelled) return; + + setActiveDeploymentCount( + deploymentsResult.status === 'fulfilled' + ? countActiveDeployments(deploymentsResult.value.list) + : null + ); + setLinkedProxyCount( + linkedProxiesResult.status === 'fulfilled' + ? (linkedProxiesResult.value.count ?? 0) + : null + ); + }; + + resolveDeletePreconditions().catch((err) => { + logger.error( + 'Failed to resolve LLM Provider delete pre-conditions:', + err + ); + if (isCancelled) return; + setActiveDeploymentCount(null); + setLinkedProxyCount(null); + }); + + return () => { + isCancelled = true; + }; + }, [ + currentOrganization?.uuid, + provider?.id, + canDelete, + isReadOnlyProvider, + apimBaseUrl, + ]); + + const deleteBlockedReason = useMemo(() => { + if (isReadOnlyProvider && activeDeploymentCount !== null && activeDeploymentCount > 0) { + return activeDeploymentDeleteBlockedReason( + 'LLM Provider', + activeDeploymentCount + ); + } + if (linkedProxyCount !== null && linkedProxyCount > 0) { + return linkedProxiesDeleteBlockedReason(linkedProxyCount); + } + return null; + }, [isReadOnlyProvider, activeDeploymentCount, linkedProxyCount]); + const canCreateProxy = hasPermission(SCOPES.LLM_PROXY_CREATE); const isCreateProxyDisabled = !provider?.id || isProxyQuotaReached || !canCreateProxy; @@ -900,21 +975,28 @@ function ServiceProviderOverviewContent() { ); const providerDeleteAction = isAdminOrgLevel && canDelete ? ( - { - void checkProviderUsageAndConfirmDelete( - providerKey, - provider.displayName - ); - }} - aria-label={`Delete ${providerDisplayName}`} - data-cyid="delete-provider-button" + - - + { + void checkProviderUsageAndConfirmDelete( + providerKey, + provider.displayName + ); + }} + aria-label={`Delete ${providerDisplayName}`} + data-cyid="delete-provider-button" + > + + + ) : null; const renderResourcesSpecViewer = () => { diff --git a/portals/ai-workspace/src/utils/artifactDeletion.ts b/portals/ai-workspace/src/utils/artifactDeletion.ts new file mode 100644 index 0000000000..d9943cca3b --- /dev/null +++ b/portals/ai-workspace/src/utils/artifactDeletion.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DeploymentResponse, DeploymentStatus } from './types'; + +/** + * Deployment statuses the Platform API counts as "active" when deciding whether + * a gateway-created artifact may be deleted. + */ +export const ACTIVE_DEPLOYMENT_STATUSES: readonly DeploymentStatus[] = [ + 'DEPLOYED', + 'DEPLOYING', + 'UNDEPLOYING', +]; + +/** True when any deployment in `deployments` is in a delete-blocking state. */ +export function hasActiveDeployment( + deployments: DeploymentResponse[] | undefined | null +): boolean { + return (deployments ?? []).some((deployment) => + (ACTIVE_DEPLOYMENT_STATUSES as readonly string[]).includes( + String(deployment?.status ?? '').toUpperCase() + ) + ); +} + +/** Count of deployments in a delete-blocking state, for tooltip copy. */ +export function countActiveDeployments( + deployments: DeploymentResponse[] | undefined | null +): number { + return (deployments ?? []).filter((deployment) => + (ACTIVE_DEPLOYMENT_STATUSES as readonly string[]).includes( + String(deployment?.status ?? '').toUpperCase() + ) + ).length; +} + +/** + * Tooltip reason for a gateway-created artifact that still has active + * deployments. `artifactType` is the user-facing kind ("LLM Provider", + * "App LLM Proxy", "MCP Proxy"). + */ +export function activeDeploymentDeleteBlockedReason( + artifactType: string, + activeCount: number +): string { + const gatewayLabel = activeCount === 1 ? 'gateway' : 'gateways'; + const countLabel = activeCount > 0 ? `${activeCount} ${gatewayLabel}` : 'a gateway'; + return ( + `This ${artifactType} was created from a gateway and is still deployed on ${countLabel}. ` + + `Undeploy it from the ${gatewayLabel} first, then delete it here.` + ); +} + +/** + * Tooltip reason for an LLM Provider that still has App LLM Proxies built on + * it. Deleting the provider would orphan them, so they have to go first. + */ +export function linkedProxiesDeleteBlockedReason(linkedProxyCount: number): string { + const proxyLabel = linkedProxyCount === 1 ? 'App LLM Proxy' : 'App LLM Proxies'; + const usageVerb = linkedProxyCount === 1 ? 'is' : 'are'; + return ( + `${linkedProxyCount} ${proxyLabel} ${usageVerb} using this LLM Provider. ` + + `Delete or repoint ${linkedProxyCount === 1 ? 'it' : 'them'} before deleting the provider.` + ); +}