first commit
This commit is contained in:
434
ui/app/workspace/model-limits/views/modelLimitsTable.tsx
Normal file
434
ui/app/workspace/model-limits/views/modelLimitsTable.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { resetDurationLabels } from "@/lib/constants/governance";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { ProviderLabels, ProviderName } from "@/lib/constants/logs";
|
||||
import { getErrorMessage, useDeleteModelConfigMutation } from "@/lib/store";
|
||||
import { ModelConfig } from "@/lib/types/governance";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatCurrency } from "@/lib/utils/governance";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { ChevronLeft, ChevronRight, Edit, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import ModelLimitSheet from "./modelLimitSheet";
|
||||
import { ModelLimitsEmptyState } from "./modelLimitsEmptyState";
|
||||
|
||||
// Helper to format reset duration for display
|
||||
const formatResetDuration = (duration: string) => {
|
||||
return resetDurationLabels[duration] || duration;
|
||||
};
|
||||
|
||||
const toTestIdPart = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
interface ModelLimitsTableProps {
|
||||
modelConfigs: ModelConfig[];
|
||||
totalCount: number;
|
||||
search: string;
|
||||
debouncedSearch: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onOffsetChange: (offset: number) => void;
|
||||
}
|
||||
|
||||
export default function ModelLimitsTable({
|
||||
modelConfigs,
|
||||
totalCount,
|
||||
search,
|
||||
debouncedSearch,
|
||||
onSearchChange,
|
||||
offset,
|
||||
limit,
|
||||
onOffsetChange,
|
||||
}: ModelLimitsTableProps) {
|
||||
const [showModelLimitSheet, setShowModelLimitSheet] = useState(false);
|
||||
const [editingModelConfigId, setEditingModelConfigId] = useState<string | null>(null);
|
||||
|
||||
// Derive editingModelConfig from props so it stays in sync with RTK cache updates
|
||||
const editingModelConfig = useMemo(
|
||||
() => (editingModelConfigId ? (modelConfigs.find((mc) => mc.id === editingModelConfigId) ?? null) : null),
|
||||
[editingModelConfigId, modelConfigs],
|
||||
);
|
||||
|
||||
const hasCreateAccess = useRbac(RbacResource.Governance, RbacOperation.Create);
|
||||
const hasUpdateAccess = useRbac(RbacResource.Governance, RbacOperation.Update);
|
||||
const hasDeleteAccess = useRbac(RbacResource.Governance, RbacOperation.Delete);
|
||||
|
||||
const [deleteModelConfig, { isLoading: isDeleting }] = useDeleteModelConfigMutation();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteModelConfig(id).unwrap();
|
||||
toast.success("Model limit deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModelLimit = () => {
|
||||
setEditingModelConfigId(null);
|
||||
setShowModelLimitSheet(true);
|
||||
};
|
||||
|
||||
const handleEditModelLimit = (config: ModelConfig, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingModelConfigId(config.id);
|
||||
setShowModelLimitSheet(true);
|
||||
};
|
||||
|
||||
const handleModelLimitSaved = () => {
|
||||
setShowModelLimitSheet(false);
|
||||
setEditingModelConfigId(null);
|
||||
};
|
||||
|
||||
const hasActiveFilters = debouncedSearch;
|
||||
|
||||
// True empty state: no model limits at all (not just filtered to zero)
|
||||
if (totalCount === 0 && !hasActiveFilters) {
|
||||
return (
|
||||
<>
|
||||
{showModelLimitSheet && (
|
||||
<ModelLimitSheet modelConfig={editingModelConfig} onSave={handleModelLimitSaved} onCancel={() => setShowModelLimitSheet(false)} />
|
||||
)}
|
||||
<ModelLimitsEmptyState onAddClick={handleAddModelLimit} canCreate={hasCreateAccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showModelLimitSheet && (
|
||||
<ModelLimitSheet modelConfig={editingModelConfig} onSave={handleModelLimitSaved} onCancel={() => setShowModelLimitSheet(false)} />
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Model Limits</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure budgets and rate limits at the model level. For provider-specific limits, visit each provider's settings.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddModelLimit} disabled={!hasCreateAccess} data-testid="model-limits-button-create">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Model Limit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
aria-label="Search model limits by model name"
|
||||
placeholder="Search by model name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
data-testid="model-limits-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-sm border" data-testid="model-limits-table">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-medium">Model</TableHead>
|
||||
<TableHead className="font-medium">Provider</TableHead>
|
||||
<TableHead className="font-medium">Budget</TableHead>
|
||||
<TableHead className="font-medium">Rate Limit</TableHead>
|
||||
<TableHead className="w-[100px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{modelConfigs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
<span className="text-muted-foreground text-sm">No matching model limits found.</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
modelConfigs.map((config) => {
|
||||
const isBudgetExhausted =
|
||||
config.budget?.max_limit && config.budget.max_limit > 0 && config.budget.current_usage >= config.budget.max_limit;
|
||||
const isRateLimitExhausted =
|
||||
(config.rate_limit?.token_max_limit &&
|
||||
config.rate_limit.token_max_limit > 0 &&
|
||||
config.rate_limit.token_current_usage >= config.rate_limit.token_max_limit) ||
|
||||
(config.rate_limit?.request_max_limit &&
|
||||
config.rate_limit.request_max_limit > 0 &&
|
||||
config.rate_limit.request_current_usage >= config.rate_limit.request_max_limit);
|
||||
const isExhausted = isBudgetExhausted || isRateLimitExhausted;
|
||||
|
||||
// Compute safe percentages to avoid division by zero
|
||||
const budgetPercentage =
|
||||
config.budget?.max_limit && config.budget.max_limit > 0
|
||||
? Math.min((config.budget.current_usage / config.budget.max_limit) * 100, 100)
|
||||
: 0;
|
||||
const tokenPercentage =
|
||||
config.rate_limit?.token_max_limit && config.rate_limit.token_max_limit > 0
|
||||
? Math.min((config.rate_limit.token_current_usage / config.rate_limit.token_max_limit) * 100, 100)
|
||||
: 0;
|
||||
const requestPercentage =
|
||||
config.rate_limit?.request_max_limit && config.rate_limit.request_max_limit > 0
|
||||
? Math.min((config.rate_limit.request_current_usage / config.rate_limit.request_max_limit) * 100, 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={config.id}
|
||||
data-testid={`model-limit-row-${toTestIdPart(config.model_name)}-${toTestIdPart(config.provider || "all")}`}
|
||||
className={cn("group transition-colors", isExhausted && "bg-red-500/5 hover:bg-red-500/10")}
|
||||
>
|
||||
<TableCell className="max-w-[280px] py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="truncate font-mono text-sm font-medium">{config.model_name}</span>
|
||||
{isExhausted && (
|
||||
<Badge variant="destructive" className="w-fit text-xs">
|
||||
Limit Reached
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{config.provider ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<RenderProviderIcon provider={config.provider as ProviderIconType} size="sm" className="h-4 w-4" />
|
||||
<span className="text-sm">{ProviderLabels[config.provider as ProviderName] || config.provider}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">All Providers</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[180px]">
|
||||
{config.budget ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="font-medium">{formatCurrency(config.budget.max_limit)}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatResetDuration(config.budget.reset_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={budgetPercentage}
|
||||
className={cn(
|
||||
"bg-muted/70 dark:bg-muted/30 h-1.5",
|
||||
isBudgetExhausted
|
||||
? "[&>div]:bg-red-500/70"
|
||||
: budgetPercentage > 80
|
||||
? "[&>div]:bg-amber-500/70"
|
||||
: "[&>div]:bg-emerald-500/70",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">
|
||||
{formatCurrency(config.budget.current_usage)} / {formatCurrency(config.budget.max_limit)}
|
||||
</p>
|
||||
<p className="text-primary-foreground/80 text-xs">
|
||||
Resets {formatResetDuration(config.budget.reset_duration)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[180px]">
|
||||
{config.rate_limit ? (
|
||||
<div className="space-y-2.5">
|
||||
{config.rate_limit.token_max_limit && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-4 text-xs">
|
||||
<span className="font-medium">{config.rate_limit.token_max_limit.toLocaleString()} tokens</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatResetDuration(config.rate_limit.token_reset_duration || "1h")}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={tokenPercentage}
|
||||
className={cn(
|
||||
"bg-muted/70 dark:bg-muted/30 h-1",
|
||||
config.rate_limit.token_current_usage >= config.rate_limit.token_max_limit
|
||||
? "[&>div]:bg-red-500/70"
|
||||
: tokenPercentage > 80
|
||||
? "[&>div]:bg-amber-500/70"
|
||||
: "[&>div]:bg-emerald-500/70",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">
|
||||
{config.rate_limit.token_current_usage.toLocaleString()} /{" "}
|
||||
{config.rate_limit.token_max_limit.toLocaleString()} tokens
|
||||
</p>
|
||||
<p className="text-primary-foreground/80 text-xs">
|
||||
Resets {formatResetDuration(config.rate_limit.token_reset_duration || "1h")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{config.rate_limit.request_max_limit && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-4 text-xs">
|
||||
<span className="font-medium">{config.rate_limit.request_max_limit.toLocaleString()} req</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatResetDuration(config.rate_limit.request_reset_duration || "1h")}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={requestPercentage}
|
||||
className={cn(
|
||||
"bg-muted/70 dark:bg-muted/30 h-1",
|
||||
config.rate_limit.request_current_usage >= config.rate_limit.request_max_limit
|
||||
? "[&>div]:bg-red-500/70"
|
||||
: requestPercentage > 80
|
||||
? "[&>div]:bg-amber-500/70"
|
||||
: "[&>div]:bg-emerald-500/70",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">
|
||||
{config.rate_limit.request_current_usage.toLocaleString()} /{" "}
|
||||
{config.rate_limit.request_max_limit.toLocaleString()} requests
|
||||
</p>
|
||||
<p className="text-primary-foreground/80 text-xs">
|
||||
Resets {formatResetDuration(config.rate_limit.request_reset_duration || "1h")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEditModelLimit(config, e)}
|
||||
disabled={!hasUpdateAccess}
|
||||
aria-label={`Edit model limit for ${config.model_name}`}
|
||||
data-testid={`model-limit-button-edit-${toTestIdPart(config.model_name)}-${toTestIdPart(config.provider || "all")}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-500/10 hover:text-red-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!hasDeleteAccess}
|
||||
aria-label={`Delete model limit for ${config.model_name}`}
|
||||
data-testid={`model-limit-button-delete-${toTestIdPart(config.model_name)}-${toTestIdPart(config.provider || "all")}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Model Limit</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the limit for "
|
||||
{config.model_name.length > 30 ? `${config.model_name.slice(0, 30)}...` : config.model_name}
|
||||
"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(config.id)}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
|
||||
data-testid="model-limits-pagination-prev-btn"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset + limit >= totalCount}
|
||||
onClick={() => onOffsetChange(offset + limit)}
|
||||
data-testid="model-limits-pagination-next-btn"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user