Files
bifrost/ui/app/workspace/model-limits/views/modelLimitsTable.tsx
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

434 lines
17 KiB
TypeScript

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&apos;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 &quot;
{config.model_name.length > 30 ? `${config.model_name.slice(0, 30)}...` : config.model_name}
&quot;? 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>
</>
);
}