275 lines
9.1 KiB
TypeScript
275 lines
9.1 KiB
TypeScript
/**
|
|
* Routing Rules Table
|
|
* Displays all routing rules with CRUD actions
|
|
*/
|
|
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alertDialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
|
import { getProviderLabel } from "@/lib/constants/logs";
|
|
import { getErrorMessage } from "@/lib/store";
|
|
import { useDeleteRoutingRuleMutation } from "@/lib/store/apis/routingRulesApi";
|
|
import { RoutingRule, RoutingTarget } from "@/lib/types/routingRules";
|
|
import { getPriorityBadgeClass, getScopeLabel, truncateCELExpression } from "@/lib/utils/routingRules";
|
|
import { ChevronLeft, ChevronRight, Edit, Search, Trash2 } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
interface RoutingRulesTableProps {
|
|
rules: RoutingRule[] | undefined;
|
|
totalCount: number;
|
|
isLoading: boolean;
|
|
onEdit: (rule: RoutingRule) => void;
|
|
onRowClick: (rule: RoutingRule) => void;
|
|
/** When false, delete button is hidden and deletion is disabled (e.g. for read-only users). */
|
|
canDelete?: boolean;
|
|
search: string;
|
|
onSearchChange: (value: string) => void;
|
|
offset: number;
|
|
limit: number;
|
|
onOffsetChange: (offset: number) => void;
|
|
}
|
|
|
|
export function RoutingRulesTable({
|
|
rules,
|
|
totalCount,
|
|
isLoading,
|
|
onEdit,
|
|
onRowClick,
|
|
canDelete = false,
|
|
search,
|
|
onSearchChange,
|
|
offset,
|
|
limit,
|
|
onOffsetChange,
|
|
}: RoutingRulesTableProps) {
|
|
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null);
|
|
const [deleteRoutingRule, { isLoading: isDeleting }] = useDeleteRoutingRuleMutation();
|
|
|
|
const handleDelete = async () => {
|
|
if (!canDelete || !deleteRuleId) return;
|
|
|
|
try {
|
|
await deleteRoutingRule(deleteRuleId).unwrap();
|
|
toast.success("Routing rule deleted successfully");
|
|
setDeleteRuleId(null);
|
|
} catch (error: any) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="rounded-sm border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Targets</TableHead>
|
|
<TableHead>Scope</TableHead>
|
|
<TableHead className="text-right">Priority</TableHead>
|
|
<TableHead>Expression</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[...Array(5)].map((_, i) => (
|
|
<TableRow key={i}>
|
|
<TableCell colSpan={7} className="h-10">
|
|
<div className="bg-muted h-2 w-32 animate-pulse rounded" />
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const sortedRules = rules ? [...rules].sort((a, b) => a.priority - b.priority) : [];
|
|
const ruleToDelete = sortedRules.find((r) => r.id === deleteRuleId);
|
|
|
|
return (
|
|
<>
|
|
{/* 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 routing rules by name"
|
|
placeholder="Search by name..."
|
|
value={search}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="pl-9"
|
|
data-testid="routing-rules-search-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-sm border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50">
|
|
<TableHead className="font-semibold">Name</TableHead>
|
|
<TableHead className="font-semibold">Targets</TableHead>
|
|
<TableHead className="font-semibold">Scope</TableHead>
|
|
<TableHead className="text-right font-semibold">Priority</TableHead>
|
|
<TableHead className="font-semibold">Expression</TableHead>
|
|
<TableHead className="font-semibold">Status</TableHead>
|
|
<TableHead className="text-right font-semibold">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sortedRules.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="h-24 text-center">
|
|
<span className="text-muted-foreground text-sm">No matching routing rules found.</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
sortedRules.map((rule) => (
|
|
<TableRow key={rule.id} className="hover:bg-muted/50 cursor-pointer transition-colors" onClick={() => onRowClick(rule)}>
|
|
<TableCell className="font-medium">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="max-w-xs truncate">{rule.name}</span>
|
|
{rule.description && (
|
|
<span data-testid="routing-rule-description" className="text-muted-foreground max-w-xs truncate text-xs">
|
|
{rule.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TargetsSummary targets={rule.targets || []} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="secondary">{getScopeLabel(rule.scope)}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className={`inline-block rounded px-2.5 py-1 text-xs font-medium ${getPriorityBadgeClass()}`}>{rule.priority}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-muted-foreground block max-w-xs truncate font-mono text-xs" title={rule.cel_expression}>
|
|
{truncateCELExpression(rule.cel_expression)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={rule.enabled ? "default" : "secondary"}>{rule.enabled ? "Enabled" : "Disabled"}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onEdit(rule)}
|
|
aria-label="Edit routing rule"
|
|
data-testid={`routing-rule-edit-${rule.id}-btn`}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
{canDelete && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeleteRuleId(rule.id)}
|
|
aria-label="Delete routing rule"
|
|
data-testid={`routing-rule-delete-${rule.id}-btn`}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</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="routing-rules-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="routing-rules-pagination-next-btn"
|
|
>
|
|
Next
|
|
<ChevronRight className="ml-1 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<AlertDialog open={!!deleteRuleId} onOpenChange={(open) => !open && setDeleteRuleId(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Routing Rule</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete "{ruleToDelete?.name}"? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-destructive hover:bg-destructive/90">
|
|
{isDeleting ? "Deleting..." : "Delete"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TargetsSummary({ targets }: { targets: RoutingTarget[] }) {
|
|
if (!targets || targets.length === 0) {
|
|
return <span className="text-muted-foreground text-sm">-</span>;
|
|
}
|
|
|
|
const first = targets[0];
|
|
const label = [first.provider ? getProviderLabel(first.provider) : "Any", first.model || "Any model"].join(" / ");
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-1.5">
|
|
{first.provider && <RenderProviderIcon provider={first.provider as ProviderIconType} size="sm" className="h-4 w-4 shrink-0" />}
|
|
<span className="max-w-[160px] truncate text-sm">{label}</span>
|
|
</div>
|
|
{targets.length > 1 && (
|
|
<span className="text-muted-foreground text-xs">
|
|
+{targets.length - 1} more target{targets.length > 2 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
} |