first commit
This commit is contained in:
300
ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
Normal file
300
ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useDeleteProviderKeyMutation, useGetProviderKeysQuery, useUpdateProviderKeyMutation } from "@/lib/store/apis/providersApi";
|
||||
import { ModelProvider } from "@/lib/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { AlertCircle, CheckCircle2, EllipsisIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import AddNewKeySheet from "../dialogs/addNewKeySheet";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
provider: ModelProvider;
|
||||
headerActions?: ReactNode;
|
||||
isKeyless?: boolean;
|
||||
}
|
||||
|
||||
export default function ModelProviderKeysTableView({ provider, className, headerActions, isKeyless }: Props) {
|
||||
const providerName = provider.name?.toLowerCase() ?? "";
|
||||
const isVLLM = providerName === "vllm";
|
||||
const isOllamaOrSGL = providerName === "ollama" || providerName === "sgl";
|
||||
const entityLabel = isVLLM ? "model" : isOllamaOrSGL ? "server" : "key";
|
||||
const entityLabelPlural = isVLLM ? "models" : isOllamaOrSGL ? "servers" : "keys";
|
||||
const EntityLabel = entityLabel.charAt(0).toUpperCase() + entityLabel.slice(1);
|
||||
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
|
||||
const hasDeleteProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Delete);
|
||||
const [updateProviderKey, { isLoading: isUpdatingProviderKey }] = useUpdateProviderKeyMutation();
|
||||
const [deleteProviderKey, { isLoading: isDeletingProviderKey }] = useDeleteProviderKeyMutation();
|
||||
const { data: keys = [] } = useGetProviderKeysQuery(provider.name);
|
||||
const isMutatingProviderKey = isUpdatingProviderKey || isDeletingProviderKey;
|
||||
const [togglingKeyIds, setTogglingKeyIds] = useState<Set<string>>(new Set());
|
||||
const [showAddNewKeyDialog, setShowAddNewKeyDialog] = useState<{ show: boolean; keyId: string | null } | undefined>(undefined);
|
||||
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState<{ show: boolean; keyId: string } | undefined>(undefined);
|
||||
|
||||
function handleAddKey() {
|
||||
setShowAddNewKeyDialog({ show: true, keyId: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{showDeleteKeyDialog && (
|
||||
<AlertDialog open={showDeleteKeyDialog.show}>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {EntityLabel}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this {entityLabel}. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="pt-4">
|
||||
<AlertDialogCancel onClick={() => setShowDeleteKeyDialog(undefined)} disabled={isMutatingProviderKey}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isMutatingProviderKey || !hasDeleteProviderAccess}
|
||||
onClick={() => {
|
||||
deleteProviderKey({
|
||||
provider: provider.name,
|
||||
keyId: showDeleteKeyDialog.keyId,
|
||||
})
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
toast.success(`${EntityLabel} deleted successfully`);
|
||||
setShowDeleteKeyDialog(undefined);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`Failed to delete ${entityLabel}`, {
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{showAddNewKeyDialog && (
|
||||
<AddNewKeySheet
|
||||
show={showAddNewKeyDialog.show}
|
||||
onCancel={() => setShowAddNewKeyDialog(undefined)}
|
||||
provider={provider}
|
||||
keyId={showAddNewKeyDialog.keyId}
|
||||
providerName={providerName}
|
||||
/>
|
||||
)}
|
||||
<CardHeader className="mb-4 px-0">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">Configured {entityLabelPlural}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{!isKeyless && (
|
||||
<Button
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
data-testid="add-key-btn"
|
||||
onClick={() => {
|
||||
handleAddKey();
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add new {entityLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{isKeyless ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-sm border py-10 text-center text-sm">
|
||||
<p>This is a keyless provider - no API keys are required.</p>
|
||||
<p>You can edit the provider configuration using the button above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-2 rounded-sm border">
|
||||
<Table className="w-full" data-testid="keys-table">
|
||||
<TableHeader className="w-full">
|
||||
<TableRow>
|
||||
<TableHead>{isVLLM ? "Model" : isOllamaOrSGL ? "Server" : "API Key"}</TableHead>
|
||||
<TableHead>Weight</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead className="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.length === 0 && (
|
||||
<TableRow data-testid="keys-table-empty-state">
|
||||
<TableCell colSpan={4} className="py-6 text-center">
|
||||
No {entityLabelPlural} found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{keys.map((key) => {
|
||||
const isKeyEnabled = key.enabled ?? true;
|
||||
return (
|
||||
<TableRow
|
||||
key={key.id}
|
||||
data-testid={`key-row-${key.name}`}
|
||||
className="text-sm transition-colors hover:bg-white"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{key.status === "success" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Key status: list models working"
|
||||
data-testid={`key-status-success-${key.name}`}
|
||||
className="inline-flex"
|
||||
>
|
||||
<CheckCircle2 aria-hidden className="h-4 w-4 flex-shrink-0 text-green-600" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>List models working</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{key.status === "list_models_failed" &&
|
||||
(() => {
|
||||
// Check if the failure might be due to an env var that the server couldn't resolve
|
||||
const hasEnvVarConfig =
|
||||
key.azure_key_config?.endpoint?.from_env ||
|
||||
key.vertex_key_config?.project_id?.from_env ||
|
||||
key.vertex_key_config?.region?.from_env ||
|
||||
key.bedrock_key_config?.region?.from_env ||
|
||||
key.vllm_key_config?.url?.from_env ||
|
||||
key.value?.from_env;
|
||||
const isEnvResolutionError =
|
||||
hasEnvVarConfig && key.description && /not set|empty|missing/i.test(key.description);
|
||||
|
||||
return isEnvResolutionError ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Key status: env var may not be resolved"
|
||||
data-testid={`key-status-warning-${key.name}`}
|
||||
className="inline-flex"
|
||||
>
|
||||
<AlertCircle aria-hidden className="h-4 w-4 flex-shrink-0 text-orange-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">
|
||||
{key.description} — verify the environment variable is set on the server
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Key status: list models failed"
|
||||
data-testid={`key-status-error-${key.name}`}
|
||||
className="inline-flex"
|
||||
>
|
||||
<AlertCircle aria-hidden className="text-destructive h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">
|
||||
{key.description || "Model discovery failed for this key"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
<span className="font-mono text-sm">{key.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell data-testid="key-weight-value">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-sm">{key.weight}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
data-testid="key-enabled-switch"
|
||||
checked={isKeyEnabled}
|
||||
size="md"
|
||||
disabled={!hasUpdateProviderAccess || togglingKeyIds.has(key.id)}
|
||||
onAsyncCheckedChange={async (checked) => {
|
||||
setTogglingKeyIds((prev) => new Set(prev).add(key.id));
|
||||
await updateProviderKey({
|
||||
provider: provider.name,
|
||||
keyId: key.id,
|
||||
key: { ...key, enabled: checked },
|
||||
})
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
toast.success(`${EntityLabel} ${checked ? "enabled" : "disabled"} successfully`);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`Failed to update ${entityLabel}`, { description: getErrorMessage(err) });
|
||||
})
|
||||
.finally(() => {
|
||||
setTogglingKeyIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key.id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button onClick={(e) => e.stopPropagation()} variant="ghost">
|
||||
<EllipsisIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setShowAddNewKeyDialog({ show: true, keyId: key.id });
|
||||
}}
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
>
|
||||
<PencilIcon className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowDeleteKeyDialog({ show: true, keyId: key.id });
|
||||
}}
|
||||
disabled={!hasDeleteProviderAccess}
|
||||
>
|
||||
<TrashIcon className="mr-1 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user