mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): cascade disable models when deleting a provider
Provider deletion now disables associated models (enabled=false) instead of soft-deleting them. Disabled models remain visible in the admin model list so they can be reassigned to a different provider later. Changes: - ProviderForm: cascade calls onDisableModel (update with enabled:false) instead of onDeleteModel, dialog description says 'disable', escape guard prevents dismissal during cascade. - UpdateProviderPageView: replaced old DeleteDialog with type-to-confirm Dialog, cascade disables models via updateChatModelConfig, invalidates all 3 query keys via invalidateChatConfigurationQueries, guards against isLoading/isError on model configs query. - ModelsSection: disabled badge uses grey (variant=default) instead of orange (variant=warning). - AI Settings ProviderForm: name field description updated. - chats.ts: export invalidateChatConfigurationQueries for reuse.
This commit is contained in:
@@ -1743,7 +1743,9 @@ export const deleteUserChatProviderKey = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => {
|
||||
export const invalidateChatConfigurationQueries = async (
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: chatProviderConfigsKey }),
|
||||
queryClient.invalidateQueries({ queryKey: chatModelConfigsKey }),
|
||||
|
||||
+142
-30
@@ -4,18 +4,31 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Link, Navigate, useNavigate, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { API } from "#/api/api";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
aiProvider,
|
||||
deleteAIProviderMutation,
|
||||
updateAIProviderMutation,
|
||||
} from "#/api/queries/aiProviders";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
invalidateChatConfigurationQueries,
|
||||
} from "#/api/queries/chats";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { ProviderForm } from "../components/ProviderForm";
|
||||
@@ -36,6 +49,8 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [isCascadeDeleting, setIsCascadeDeleting] = useState(false);
|
||||
|
||||
const providerQuery = useQuery({
|
||||
...aiProvider(providerId ?? ""),
|
||||
@@ -54,6 +69,16 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
deleteAIProviderMutation(queryClient, providerId ?? ""),
|
||||
);
|
||||
|
||||
const modelConfigsQuery = useQuery({
|
||||
...chatModelConfigs(),
|
||||
enabled: Boolean(providerId),
|
||||
});
|
||||
|
||||
const associatedModels = (modelConfigsQuery.data ?? []).filter(
|
||||
(mc) => mc.ai_provider_id === provider?.id,
|
||||
);
|
||||
const associatedModelCount = associatedModels.length;
|
||||
|
||||
// Rendered into every non-redirect return so the document title reflects
|
||||
// the provider as soon as we know it; falls back to a placeholder while
|
||||
// the query is in flight.
|
||||
@@ -210,36 +235,123 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DeleteDialog
|
||||
key={provider.name}
|
||||
isOpen={deleteDialogOpen}
|
||||
title="Delete provider"
|
||||
entity="provider"
|
||||
name={provider.name}
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isCascadeDeleting && !deleteMutation.isPending) {
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteConfirmText("");
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`Provider "${provider.display_name || provider.name}" deleted.`,
|
||||
);
|
||||
setDeleteDialogOpen(false);
|
||||
void navigate(BACK_HREF, { replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
`Failed to delete provider "${provider.display_name || provider.name}".`,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DialogContent variant="destructive">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete provider</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 text-sm text-content-secondary">
|
||||
<p className="m-0 font-medium text-content-destructive">
|
||||
Deleting this provider is irreversible!
|
||||
</p>
|
||||
{associatedModelCount > 0 && (
|
||||
<ul className="m-0 pl-5">
|
||||
<li>
|
||||
Deleting this provider will also disable{" "}
|
||||
<strong className="text-content-primary">
|
||||
{associatedModelCount}{" "}
|
||||
{associatedModelCount === 1 ? "model" : "models"}
|
||||
</strong>{" "}
|
||||
from your settings.
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
<p className="m-0">
|
||||
Type{" "}
|
||||
<strong className="text-content-primary">
|
||||
{provider.name}
|
||||
</strong>{" "}
|
||||
to confirm.
|
||||
</p>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
aria-label={`Type ${provider.name} to confirm`}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder={provider.name}
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteConfirmText("");
|
||||
}}
|
||||
disabled={isCascadeDeleting || deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="disabled:border-border"
|
||||
disabled={
|
||||
deleteConfirmText !== provider.name ||
|
||||
isCascadeDeleting ||
|
||||
deleteMutation.isPending ||
|
||||
modelConfigsQuery.isLoading ||
|
||||
modelConfigsQuery.isError
|
||||
}
|
||||
onClick={() => {
|
||||
const deleteAll = async () => {
|
||||
setIsCascadeDeleting(true);
|
||||
try {
|
||||
for (const mc of associatedModels) {
|
||||
await API.experimental.updateChatModelConfig(mc.id, {
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
await invalidateChatConfigurationQueries(queryClient);
|
||||
deleteMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`Provider "${provider.display_name || provider.name}" deleted.`,
|
||||
);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteConfirmText("");
|
||||
void navigate(BACK_HREF, { replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
`Failed to delete provider "${provider.display_name || provider.name}".`,
|
||||
),
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsCascadeDeleting(false);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to delete provider."),
|
||||
);
|
||||
setIsCascadeDeleting(false);
|
||||
await invalidateChatConfigurationQueries(queryClient);
|
||||
}
|
||||
};
|
||||
void deleteAll();
|
||||
}}
|
||||
>
|
||||
{(isCascadeDeleting || deleteMutation.isPending) && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
Delete provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -303,7 +303,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
required
|
||||
field={getFieldHelpers("name")}
|
||||
label="Name"
|
||||
description="Unique identifier (used in urls, can't be changed)"
|
||||
description="URL identifier. Cannot be changed."
|
||||
className="w-full"
|
||||
placeholder={namePlaceholder(form.values.type)}
|
||||
disabled={editing}
|
||||
@@ -341,7 +341,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
required
|
||||
field={getFieldHelpers("name")}
|
||||
label="Name"
|
||||
description="Unique identifier (used in urls, can't be changed)"
|
||||
description="URL identifier. Cannot be changed."
|
||||
className="w-full"
|
||||
placeholder={namePlaceholder(form.values.type)}
|
||||
disabled={editing}
|
||||
|
||||
@@ -324,6 +324,7 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
onCreateProvider={onCreateProvider}
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
onDisableModel={onUpdateModel}
|
||||
/>
|
||||
) : (
|
||||
<ModelsSection
|
||||
|
||||
@@ -377,7 +377,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{modelConfig.enabled === false && (
|
||||
<Badge size="xs" variant="warning">
|
||||
<Badge size="xs" variant="default">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -45,6 +47,10 @@ interface ProviderFormProps {
|
||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
onDisableModel: (
|
||||
modelConfigId: string,
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
@@ -55,6 +61,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
onDisableModel,
|
||||
onBack,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -80,6 +87,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
const [apiKeyModified, setApiKeyModified] = useState(false);
|
||||
const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [isCascadeDeleting, setIsCascadeDeleting] = useState(false);
|
||||
|
||||
const isBedrockProvider = provider === "bedrock";
|
||||
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
|
||||
@@ -106,10 +114,11 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
? "Bedrock runtime endpoint. Use the AWS region for the models this provider should call."
|
||||
: "Endpoint used to call this provider.";
|
||||
const apiKeyPlaceholder = isBedrockProvider ? "Enter bearer token" : "sk-...";
|
||||
const associatedModelCount = providerState.modelConfigs.length;
|
||||
const deleteProviderDescription =
|
||||
"Are you sure you want to delete this provider? The provider will be " +
|
||||
"disabled and hidden from new model configuration. Existing model " +
|
||||
"configs that reference it remain saved but cannot run until updated.";
|
||||
associatedModelCount > 0
|
||||
? `Deleting this provider will also disable ${associatedModelCount} ${associatedModelCount === 1 ? "model" : "models"} from your settings.`
|
||||
: "Are you sure you want to delete this provider? This action is irreversible.";
|
||||
const hasNewProviderConfiguration = !providerConfig;
|
||||
|
||||
const isDirty =
|
||||
@@ -371,10 +380,29 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
<ConfirmDeleteDialog
|
||||
entity="provider"
|
||||
description={deleteProviderDescription}
|
||||
onConfirm={() => void onDeleteProvider(providerConfig.id)}
|
||||
isPending={isProviderMutationPending}
|
||||
onConfirm={() => {
|
||||
setIsCascadeDeleting(true);
|
||||
const chain = providerState.modelConfigs.reduce<Promise<unknown>>(
|
||||
(prev, mc) =>
|
||||
prev.then(() => onDisableModel(mc.id, { enabled: false })),
|
||||
Promise.resolve(),
|
||||
);
|
||||
chain
|
||||
.then(() => onDeleteProvider(providerConfig.id))
|
||||
.catch((error: unknown) => {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to delete provider."),
|
||||
);
|
||||
})
|
||||
.then(() => setIsCascadeDeleting(false));
|
||||
}}
|
||||
isPending={isCascadeDeleting || isProviderMutationPending}
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isCascadeDeleting && !isProviderMutationPending) {
|
||||
setConfirmingDelete(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type AIProviderType,
|
||||
AIProviderTypes,
|
||||
type CreateChatProviderConfigRequest,
|
||||
type UpdateChatModelConfigRequest,
|
||||
type UpdateChatProviderConfigRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
@@ -79,6 +80,10 @@ interface ProvidersSectionProps {
|
||||
req: UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
onDisableModel: (
|
||||
modelConfigId: string,
|
||||
req: UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
@@ -90,6 +95,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
onDisableModel,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -198,6 +204,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
}
|
||||
}}
|
||||
onBack={clearProviderView}
|
||||
onDisableModel={onDisableModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user