300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
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>
|
|
);
|
|
} |