first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,358 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DottedSeparator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { baseRoutingFields } from "@/lib/config/celFieldsRouting";
import { getOperatorLabel } from "@/lib/config/celOperatorsRouting";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { getProviderLabel } from "@/lib/constants/logs";
import { useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store/apis/governanceApi";
import { RoutingRule } from "@/lib/types/routingRules";
import { getScopeLabel } from "@/lib/utils/routingRules";
import { formatDistanceToNow } from "date-fns";
import { Check, Copy, GitMerge, Key } from "lucide-react";
import { useMemo, useState } from "react";
import { RuleGroupType, RuleType } from "react-querybuilder";
import { toast } from "sonner";
interface Props {
rule: RoutingRule | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
// ─── helpers ────────────────────────────────────────────────────────────────
function getFieldLabel(fieldName: string): string {
const field = baseRoutingFields.find((f) => f.name === fieldName);
return field?.label ?? fieldName;
}
function formatRuleValue(value: any): string {
if (Array.isArray(value)) return value.join(", ");
if (typeof value === "string") return value;
return String(value ?? "");
}
function useScopeName(scope: string, scopeId?: string): string | undefined {
const { data: teamsData } = useGetTeamsQuery(undefined, { skip: scope !== "team" || !scopeId });
const { data: customersData } = useGetCustomersQuery(undefined, { skip: scope !== "customer" || !scopeId });
const { data: vksData } = useGetVirtualKeysQuery(undefined, { skip: scope !== "virtual_key" || !scopeId });
return useMemo(() => {
if (!scopeId) return undefined;
if (scope === "team") return teamsData?.teams?.find((t) => t.id === scopeId)?.name;
if (scope === "customer") return customersData?.customers?.find((c) => c.id === scopeId)?.name;
if (scope === "virtual_key") return vksData?.virtual_keys?.find((v) => v.id === scopeId)?.name;
return undefined;
}, [scope, scopeId, teamsData, customersData, vksData]);
}
// ─── copy button ─────────────────────────────────────────────────────────────
function CopyButton({ value, label, testId }: { value: string; label?: string; testId: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Failed to copy to clipboard");
}
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={handleCopy}
aria-label={copied ? `${label ?? "value"} copied` : `Copy ${label ?? "value"}`}
data-testid={testId}
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? "Copied!" : `Copy ${label ?? "value"}`}</TooltipContent>
</Tooltip>
);
}
// ─── condition rendering ─────────────────────────────────────────────────────
function ConditionRow({ rule }: { rule: RuleType }) {
const fieldLabel = getFieldLabel(rule.field);
const opLabel = getOperatorLabel(rule.operator);
const value = formatRuleValue(rule.value);
const isExistence = rule.operator === "null" || rule.operator === "notNull";
// Detect header/param fields for richer display
const isHeader = rule.field.startsWith("headers[") || rule.field === "headers";
const isParam = rule.field.startsWith("params[") || rule.field === "params";
const keyMatch = rule.field.match(/\["([^"]+)"\]/);
// Bare field (e.g. headers / params) may encode key:value in the value string
const bareKeyValue =
!keyMatch && (isHeader || isParam) && value
? value.includes(":")
? { key: value.slice(0, value.indexOf(":")), val: value.slice(value.indexOf(":") + 1) }
: { key: value, val: "" }
: null;
const keyName = keyMatch?.[1] ?? bareKeyValue?.key;
const displayValue = bareKeyValue !== null ? bareKeyValue.val : value;
return (
<div className="flex items-start gap-1.5 px-3 py-2 text-xs">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<Badge variant="outline" className="shrink-0 font-medium">
{isHeader && keyName ? (
<span className="flex items-center gap-1">
<span className="text-muted-foreground font-normal">header</span>
<span className="font-mono">{keyName}</span>
</span>
) : isParam && keyName ? (
<span className="flex items-center gap-1">
<span className="text-muted-foreground font-normal">param</span>
<span className="font-mono">{keyName}</span>
</span>
) : (
fieldLabel
)}
</Badge>
<span className="text-muted-foreground shrink-0">{opLabel}</span>
{!isExistence && displayValue && (
<code className="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono break-all">{displayValue}</code>
)}
</div>
</div>
);
}
function CombinatorPill({ combinator }: { combinator: string }) {
return (
<div className="flex items-center gap-1.5 px-3">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground text-[10px] font-semibold uppercase">{combinator}</span>
<div className="bg-border h-px flex-1" />
</div>
);
}
function ConditionGroup({ group, depth = 0 }: { group: RuleGroupType; depth?: number }) {
const rules = group.rules ?? [];
if (rules.length === 0) return null;
const content = rules.map((rule, i) => (
<div key={i}>
{i > 0 && <CombinatorPill combinator={group.combinator} />}
{"combinator" in rule ? <ConditionGroup group={rule as RuleGroupType} depth={depth + 1} /> : <ConditionRow rule={rule as RuleType} />}
</div>
));
if (depth === 0) return <div className="rounded-md border py-1">{content}</div>;
return (
<div className="border-foreground/25 relative mx-3 my-1 rounded border border-dashed py-1">
<span className="bg-background text-muted-foreground absolute -top-2 right-2 rounded px-1 text-[10px] font-medium">Group</span>
{content}
</div>
);
}
// ─── target card ─────────────────────────────────────────────────────────────
function TargetCard({ target, total }: { target: RoutingRule["targets"][0]; index: number; total: number }) {
const providerLabel = target.provider ? getProviderLabel(target.provider) : "Incoming provider";
const weightPercent = total > 0 ? Math.round(target.weight * 100) : 0;
return (
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{target.provider && <RenderProviderIcon provider={target.provider as ProviderIconType} size="sm" className="h-5 w-5 shrink-0" />}
<div className="flex flex-col">
<span className="text-sm font-medium">{providerLabel}</span>
{target.model ? (
<span className="text-muted-foreground font-mono text-xs">{target.model}</span>
) : (
<span className="text-muted-foreground text-xs">Incoming model</span>
)}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex cursor-default items-center gap-1.5">
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
<div className="bg-primary h-full rounded-full transition-all" style={{ width: `${weightPercent}%` }} />
</div>
<span className="text-muted-foreground w-8 text-right font-mono text-xs">{weightPercent}%</span>
</div>
</TooltipTrigger>
<TooltipContent>Weight: {target.weight} (raw)</TooltipContent>
</Tooltip>
</div>
{target.key_id && (
<div className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2 py-1">
<Key className="text-muted-foreground h-3 w-3 shrink-0" />
<span className="text-muted-foreground text-xs">Pinned key:</span>
<code className="truncate font-mono text-xs">{target.key_id}</code>
<CopyButton value={target.key_id} label="key ID" testId="routing-rule-copy-key-id-btn" />
</div>
)}
</div>
);
}
// ─── fallback chain ───────────────────────────────────────────────────────────
function FallbackChain({ fallbacks }: { fallbacks: string[] }) {
return (
<div className="flex flex-wrap items-center gap-y-2">
{fallbacks.map((fb, i) => {
const parts = fb.split("/");
const provider = parts[0] || "Incoming provider";
const model = parts.length > 1 ? parts.slice(1).join("/") : "Incoming model";
return (
<div key={i} className="flex items-center">
{i > 0 && <span className="text-muted-foreground mx-1.5 text-xs">&rarr;</span>}
<Badge variant="outline" className="gap-1.5 font-normal">
{provider && <RenderProviderIcon provider={provider as ProviderIconType} size="sm" className="h-3.5 w-3.5 shrink-0" />}
<span className="font-mono text-xs">{model ? `${provider}/${model}` : fb}</span>
</Badge>
</div>
);
})}
</div>
);
}
// ─── main sheet ──────────────────────────────────────────────────────────────
export function RoutingRuleInfoSheet({ rule, open, onOpenChange }: Props) {
const targets = rule?.targets ?? [];
const fallbacks = rule?.fallbacks ?? [];
const hasQuery = rule?.query && (rule.query.rules?.length ?? 0) > 0;
const scopeName = useScopeName(rule?.scope ?? "global", rule?.scope_id);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col overflow-x-hidden p-8 sm:max-w-2xl" data-testid="routing-rule-info">
{rule && (
<>
<SheetHeader className="flex flex-col items-start gap-1 p-0">
<div className="flex w-full flex-wrap items-center gap-2">
<SheetTitle className="text-base">{rule.name}</SheetTitle>
<Badge variant={rule.enabled ? "default" : "secondary"}>{rule.enabled ? "Enabled" : "Disabled"}</Badge>
{rule.chain_rule && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="cursor-default gap-1">
<GitMerge className="h-3 w-3" />
Chain Rule
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-64">
After this rule matches, routing rules are re-evaluated using the resolved provider/model as the new context.
</TooltipContent>
</Tooltip>
)}
</div>
{rule.description && <SheetDescription className="mt-0.5 text-sm">{rule.description}</SheetDescription>}
</SheetHeader>
<div className="-mx-8 space-y-6 overflow-y-auto px-8 pb-8">
{/* Overview */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Overview</h3>
<div className="grid gap-3">
<div className="grid grid-cols-3 items-center gap-4">
<span className="text-muted-foreground text-sm">Scope</span>
<div className="col-span-2 flex items-center gap-1.5">
<Badge variant="secondary">{getScopeLabel(rule.scope)}</Badge>
{scopeName && <span className="text-sm">{scopeName}</span>}
</div>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<span className="text-muted-foreground text-sm">Priority</span>
<div className="col-span-2">
<span className="bg-primary text-primary-foreground inline-block rounded px-2.5 py-0.5 text-xs font-medium">
{rule.priority}
</span>
</div>
</div>
</div>
</div>
<DottedSeparator />
{/* Conditions */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Conditions</h3>
{hasQuery ? <ConditionGroup group={rule.query!} /> : <p className="text-muted-foreground text-sm">Matches all requests</p>}
{/* CEL expression */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">CEL Expression</span>
<CopyButton value={rule.cel_expression} label="expression" testId="routing-rule-copy-expression-btn" />
</div>
<code className="bg-muted/50 block w-full rounded-md border px-3 py-2 font-mono text-xs break-all">
{rule.cel_expression || <span className="text-muted-foreground italic">true</span>}
</code>
</div>
</div>
<DottedSeparator />
{/* Targets */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Targets ({targets.length})</h3>
{targets.length > 0 ? (
<div className="space-y-2">
{targets.map((target, i) => (
<TargetCard key={i} target={target} index={i} total={targets.length} />
))}
</div>
) : (
<p className="text-muted-foreground text-sm">No targets configured</p>
)}
</div>
<DottedSeparator />
{/* Fallback Chain */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Fallback Chain</h3>
{fallbacks.length > 0 ? (
<FallbackChain fallbacks={fallbacks} />
) : (
<p className="text-muted-foreground text-sm">No fallbacks configured</p>
)}
</div>
<DottedSeparator />
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase">Created</p>
<span className="text-sm">{formatDistanceToNow(new Date(rule.created_at), { addSuffix: true })}</span>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase">Last Updated</p>
<span className="text-sm">{formatDistanceToNow(new Date(rule.updated_at), { addSuffix: true })}</span>
</div>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,794 @@
/**
* Routing Rule Dialog (Sheet)
* Create/Edit form for routing rules
*/
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { getProviderLabel } from "@/lib/constants/logs";
import { getErrorMessage } from "@/lib/store";
import { useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store/apis/governanceApi";
import { useGetAllKeysQuery, useGetProvidersQuery } from "@/lib/store/apis/providersApi";
import { useCreateRoutingRuleMutation, useGetRoutingRulesQuery, useUpdateRoutingRuleMutation } from "@/lib/store/apis/routingRulesApi";
import {
DEFAULT_ROUTING_RULE_FORM_DATA,
DEFAULT_ROUTING_TARGET,
ROUTING_RULE_SCOPES,
RoutingRule,
RoutingRuleFormData,
RoutingTargetFormData,
} from "@/lib/types/routingRules";
import { validateRateLimitAndBudgetRules, validateRoutingRules } from "@/lib/utils/celConverterRouting";
import { Plus, Save, Trash2, X } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { RuleGroupType } from "react-querybuilder";
import { toast } from "sonner";
interface RoutingRuleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingRule?: RoutingRule | null;
onSuccess?: () => void;
}
const defaultQuery: RuleGroupType = {
combinator: "and",
rules: [],
};
// Lazy-load CEL builder (heavy dependency tree).
const CELRuleBuilderLazy = lazy(() =>
import("@/app/workspace/routing-rules/components/celBuilder/celRuleBuilder").then((mod) => ({
default: mod.CELRuleBuilder,
})),
);
const CELRuleBuilder = (props: React.ComponentProps<typeof CELRuleBuilderLazy>) => (
<Suspense fallback={<div className="text-sm text-gray-500">Loading CEL builder...</div>}>
<CELRuleBuilderLazy {...props} />
</Suspense>
);
export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }: RoutingRuleDialogProps) {
const { data: rulesData } = useGetRoutingRulesQuery();
const rules = rulesData?.rules || [];
const { data: providersData = [] } = useGetProvidersQuery();
const { data: allKeysData = [] } = useGetAllKeysQuery();
const { data: vksData = { virtual_keys: [] } } = useGetVirtualKeysQuery();
const { data: teamsData = { teams: [], count: 0, total_count: 0, limit: 0, offset: 0 } } = useGetTeamsQuery();
const { data: customersData = { customers: [] } } = useGetCustomersQuery();
const [createRoutingRule, { isLoading: isCreating }] = useCreateRoutingRuleMutation();
const [updateRoutingRule, { isLoading: isUpdating }] = useUpdateRoutingRuleMutation();
// State for targets and query (managed outside react-hook-form for complex nested structures)
const [targets, setTargets] = useState<RoutingTargetFormData[]>([{ ...DEFAULT_ROUTING_TARGET }]);
const [query, setQuery] = useState<RuleGroupType>(defaultQuery);
const [builderKey, setBuilderKey] = useState(0);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<RoutingRuleFormData>({
defaultValues: DEFAULT_ROUTING_RULE_FORM_DATA,
});
const isEditing = !!editingRule;
const isLoading = isCreating || isUpdating;
const enabled = watch("enabled");
const chainRule = watch("chain_rule");
const scope = watch("scope");
const scopeId = watch("scope_id");
const fallbacks = watch("fallbacks");
// Get available providers from configured providers, plus any provider already
// referenced by the current targets, existing rules' targets, or rules' fallbacks
// so edited/removed providers are still visible in the dropdown.
const availableProviders = Array.from(
new Set([
...providersData.map((p) => p.name),
...(targets.map((t) => t.provider).filter(Boolean) as string[]),
...(rules.flatMap((r) => r.targets?.map((t) => t.provider).filter(Boolean) ?? []) as string[]),
...rules.flatMap((r) => (r.fallbacks ?? []).map((f) => f.split("/")[0]?.trim()).filter(Boolean)),
]),
);
// Initialize form data when editing rule changes
useEffect(() => {
if (editingRule) {
setValue("id", editingRule.id);
setValue("name", editingRule.name);
setValue("description", editingRule.description);
setValue("cel_expression", editingRule.cel_expression);
setValue("fallbacks", editingRule.fallbacks || []);
setValue("scope", editingRule.scope);
setValue("scope_id", editingRule.scope_id || "");
setValue("priority", editingRule.priority);
setValue("enabled", editingRule.enabled);
setValue("chain_rule", editingRule.chain_rule ?? false);
if (editingRule.targets && editingRule.targets.length > 0) {
setTargets(
editingRule.targets.map((t) => ({
...DEFAULT_ROUTING_TARGET,
provider: t.provider || "",
model: t.model || "",
key_id: t.key_id || "",
weight: t.weight,
})),
);
} else {
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
}
// Restore the query object if it exists, otherwise use default
if (editingRule.query) {
setQuery(editingRule.query);
} else {
setQuery(defaultQuery);
}
setBuilderKey((prev) => prev + 1);
} else {
reset();
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
setQuery(defaultQuery);
setBuilderKey((prev) => prev + 1);
}
}, [editingRule, open, setValue, reset]);
const handleQueryChange = useCallback(
(expression: string, newQuery: RuleGroupType) => {
setValue("cel_expression", expression);
setQuery(newQuery);
},
[setValue],
);
const addTarget = () => {
const remaining = 1 - targets.reduce((sum, t) => sum + (t.weight || 0), 0);
setTargets((prev) => [...prev, { ...DEFAULT_ROUTING_TARGET, weight: Math.max(0, parseFloat(remaining.toFixed(4))) }]);
};
const removeTarget = (index: number) => {
setTargets((prev) => prev.filter((_, i) => i !== index));
};
const updateTarget = (index: number, field: keyof RoutingTargetFormData, value: string | number) => {
setTargets((prev) => prev.map((t, i) => (i === index ? { ...t, [field]: value } : t)));
};
const totalWeight = targets.reduce((sum, t) => sum + (t.weight || 0), 0);
const onSubmit = (data: RoutingRuleFormData) => {
// Validate scope_id is required when scope is not global
if (data.scope !== "global" && !data.scope_id?.trim()) {
toast.error(`${data.scope === "team" ? "Team" : data.scope === "customer" ? "Customer" : "Virtual Key"} is required`);
return;
}
// Validate targets
if (targets.length === 0) {
toast.error("At least one routing target is required");
return;
}
for (const t of targets) {
if (t.weight <= 0) {
toast.error("Each target weight must be greater than 0");
return;
}
}
if (Math.abs(totalWeight - 1) > 0.001) {
toast.error(`Target weights must sum to 1, current total: ${totalWeight.toFixed(4)}`);
return;
}
// Validate regex patterns in routing rules
const regexErrors = validateRoutingRules(query);
if (regexErrors.length > 0) {
toast.error(`Invalid regex pattern:\n${regexErrors.join("\n")}`);
return;
}
// Validate rate limit and budget rules
const rateLimitErrors = validateRateLimitAndBudgetRules(query);
if (rateLimitErrors.length > 0) {
toast.error(`Invalid rule configuration:\n${rateLimitErrors.join("\n")}`);
return;
}
// Filter out incomplete fallbacks (empty provider)
const validFallbacks = (data.fallbacks || []).filter((fb) => {
const provider = fb.split("/")[0]?.trim();
return provider && provider.length > 0;
});
const payload = {
name: data.name,
description: data.description,
cel_expression: data.cel_expression,
targets: targets.map(({ provider, model, key_id, weight }) => ({
provider: provider || undefined,
model: model || undefined,
key_id: key_id || undefined,
weight,
})),
fallbacks: validFallbacks,
scope: data.scope,
scope_id: data.scope === "global" ? undefined : data.scope_id || undefined,
priority: data.priority,
enabled: data.enabled,
chain_rule: data.chain_rule,
query: query,
};
const submitPromise =
isEditing && editingRule
? updateRoutingRule({
id: editingRule.id,
data: payload,
}).unwrap()
: createRoutingRule(payload).unwrap();
submitPromise
.then(() => {
toast.success(isEditing ? "Routing rule updated successfully" : "Routing rule created successfully");
reset();
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
setQuery(defaultQuery);
setBuilderKey((prev) => prev + 1);
onOpenChange(false);
onSuccess?.();
})
.catch((error: any) => {
toast.error(getErrorMessage(error));
});
};
const handleCancel = () => {
reset();
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
setQuery(defaultQuery);
setBuilderKey((prev) => prev + 1);
onOpenChange(false);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full min-w-1/2 flex-col gap-4 overflow-x-hidden p-8">
<SheetHeader className="flex flex-col items-start">
<SheetTitle>{isEditing ? "Edit Routing Rule" : "Create New Routing Rule"}</SheetTitle>
<SheetDescription>
{isEditing ? "Update the routing rule configuration" : "Create a new CEL-based routing rule for intelligent request routing"}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Rule Name */}
<div className="space-y-3">
<Label htmlFor="name">
Rule Name <span className="text-red-500">*</span>
</Label>
<Input
id="name"
placeholder="e.g., Route GPT-4 to Azure"
{...register("name", { required: "Rule name is required", maxLength: 255 })}
/>
{errors.name && <p className="text-destructive text-sm">{errors.name.message}</p>}
</div>
{/* Description */}
<div className="space-y-3">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Describe what this rule does..." rows={2} {...register("description")} />
</div>
{/* Enabled Switch */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="enabled">Enable Rule</Label>
<p className="text-muted-foreground text-sm">Rule will be active and applied to matching requests</p>
</div>
<Switch id="enabled" checked={enabled} onCheckedChange={(checked) => setValue("enabled", checked)} />
</div>
{/* Chain Rule Switch */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="chain_rule">Chain Rule</Label>
<p className="text-muted-foreground text-sm">
After this rule matches, re-evaluate routing rules using the resolved provider/model as the new context. Useful for
composing rules e.g. normalize a model alias first, then route based on the canonical name.
</p>
</div>
<Switch
id="chain_rule"
checked={chainRule}
onCheckedChange={(checked) => setValue("chain_rule", checked)}
data-testid="routing-rule-chain-rule-switch"
/>
</div>
{/* Scope and Priority - Side by Side */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-3">
<Label htmlFor="scope">Scope</Label>
<Select
value={scope}
onValueChange={(value) => {
setValue("scope", value as any);
// Clear scope_id when scope changes
setValue("scope_id", "");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select scope..." />
</SelectTrigger>
<SelectContent>
{ROUTING_RULE_SCOPES.map((scopeOption) => (
<SelectItem key={scopeOption.value} value={scopeOption.value}>
{scopeOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label htmlFor="priority">
Priority <span className="text-red-500">*</span>
</Label>
<Input
id="priority"
type="number"
min={0}
max={1000}
{...register("priority", {
required: "Priority is required",
min: { value: 0, message: "Priority must be ≥ 0" },
max: { value: 1000, message: "Priority must be ≤ 1000" },
valueAsNumber: true,
})}
/>
<p className="text-muted-foreground text-xs">Lower numbers = higher priority (0 is highest)</p>
{errors.priority && <p className="text-destructive text-sm">{errors.priority.message}</p>}
</div>
</div>
{scope !== "global" && (
<div className="space-y-2">
<Label htmlFor="scope_id">
{scope === "team" ? "Team" : scope === "customer" ? "Customer" : "Virtual Key"} <span className="text-red-500">*</span>
</Label>
{scope === "team" && teamsData.teams.length > 0 && (
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a team..." />
</SelectTrigger>
<SelectContent>
{teamsData.teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{scope === "customer" && customersData.customers.length > 0 && (
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a customer..." />
</SelectTrigger>
<SelectContent>
{customersData.customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{scope === "virtual_key" && vksData.virtual_keys.length > 0 && (
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a virtual key..." />
</SelectTrigger>
<SelectContent>
{vksData.virtual_keys.map((vk) => (
<SelectItem key={vk.id} value={vk.id}>
{vk.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{((scope === "team" && teamsData.teams.length === 0) ||
(scope === "customer" && customersData.customers.length === 0) ||
(scope === "virtual_key" && vksData.virtual_keys.length === 0)) && (
<p className="text-muted-foreground text-sm">
No {scope === "team" ? "teams" : scope === "customer" ? "customers" : "virtual keys"} available
</p>
)}
{errors.scope_id && <p className="text-destructive text-sm">{errors.scope_id.message}</p>}
</div>
)}
<Separator />
{/* CEL Rule Builder */}
<div className="space-y-3">
<Label>Rule Builder</Label>
<p className="text-muted-foreground text-sm">
Build conditions to determine when this rule should apply. Leave empty to apply this rule to all requests.
</p>
<CELRuleBuilder
key={builderKey}
initialQuery={query}
onChange={handleQueryChange}
providers={availableProviders}
models={[]}
allowCustomModels={true}
/>
</div>
{/* Note about Token/Request Limits and Budget Configuration */}
<p className="text-muted-foreground text-xs">
Note: Ensure token limits, request limits, and budget are configured in{" "}
<strong>Model Providers Configurations {"{provider}"} Governance</strong> (provider-level) or{" "}
<strong>Model Providers Budgets & Limits</strong> section (model-level) before using them in routing rules.
</p>
<Separator />
{/* Routing Targets */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Routing Targets</Label>
<p className="text-muted-foreground mt-0.5 text-xs">
Weights must sum to 1. Leave provider or model empty to use the incoming request value.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addTarget}
className="shrink-0 gap-2"
data-testid="routing-rule-target-add"
>
<Plus className="h-4 w-4" />
Add Target
</Button>
</div>
<div className="space-y-3">
{targets.map((target, index) => (
<TargetRow
key={index}
target={target}
index={index}
availableProviders={availableProviders}
allKeys={allKeysData}
showRemove={targets.length > 1}
onUpdate={updateTarget}
onRemove={removeTarget}
/>
))}
</div>
{/* Weight sum indicator */}
<div
className={`flex items-center justify-end gap-2 text-xs font-medium ${Math.abs(totalWeight - 1) > 0.001 ? "text-destructive" : "text-muted-foreground"}`}
>
Total weight: {totalWeight.toFixed(4)}
{Math.abs(totalWeight - 1) > 0.001 && <span className="text-destructive">(must equal 1)</span>}
</div>
</div>
{/* Fallbacks */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Fallbacks</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setValue("fallbacks", [...(fallbacks || []), ""])}
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Fallback
</Button>
</div>
<div className="space-y-2">
{(fallbacks || []).length === 0 ? (
<p className="text-muted-foreground text-sm">No fallbacks configured</p>
) : (
(fallbacks || []).map((fallback, index) => {
// Parse provider/model from fallback string
const parts = fallback.split("/");
const fbProvider = parts[0] || "";
const fbModel = parts[1] || "";
const handleProviderChange = (newProvider: string) => {
const model = fbModel || "";
const newFallback = `${newProvider}/${model}`;
const newFallbacks = [...fallbacks];
newFallbacks[index] = newFallback;
setValue("fallbacks", newFallbacks);
};
const handleModelChange = (newModel: string) => {
const prov = fbProvider || "";
const newFallback = `${prov}/${newModel}`;
const newFallbacks = [...fallbacks];
newFallbacks[index] = newFallback;
setValue("fallbacks", newFallbacks);
};
const handleRemove = () => {
const newFallbacks = fallbacks.filter((_: string, i: number) => i !== index);
setValue("fallbacks", newFallbacks);
};
return (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Select value={fbProvider} onValueChange={handleProviderChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{availableProviders.map((prov) => (
<SelectItem key={prov} value={prov}>
<div className="flex items-center gap-2">
<RenderProviderIcon provider={prov as ProviderIconType} size="sm" className="h-4 w-4" />
<span>{getProviderLabel(prov)}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<ModelMultiselect
provider={fbProvider || undefined}
value={fbModel}
onChange={handleModelChange}
placeholder="Select model..."
isSingleSelect
disabled={!fbProvider}
className="!h-9 !min-h-9 w-full"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRemove}
className="h-9 px-2"
aria-label={`Remove fallback ${index + 1}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})
)}
</div>
<p className="text-muted-foreground text-xs">Fallbacks will be used in the order they are defined</p>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel} disabled={isLoading}>
<X className="h-4 w-4" />
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
<Save className="h-4 w-4" />
{isEditing ? "Update Rule" : "Save Rule"}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}
interface TargetRowProps {
target: RoutingTargetFormData;
index: number;
availableProviders: string[];
allKeys: Array<{ key_id: string; name: string; provider: string }>;
showRemove: boolean;
onUpdate: (index: number, field: keyof RoutingTargetFormData, value: string | number) => void;
onRemove: (index: number) => void;
}
function TargetRow({ target, index, availableProviders, allKeys, showRemove, onUpdate, onRemove }: TargetRowProps) {
const availableKeys = target.provider
? allKeys.filter((k) => k.provider === target.provider).map((k) => ({ id: k.key_id, name: k.name }))
: [];
return (
<div className="space-y-3 rounded-lg border p-3" data-testid={`routing-target-${index}`}>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm font-medium">Target {index + 1}</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor={`routing-target-${index}-weight-input`} className="text-muted-foreground shrink-0 text-xs">
Weight
</Label>
<Input
id={`routing-target-${index}-weight-input`}
type="number"
min={0.001}
max={1}
step={0.001}
value={target.weight}
onChange={(e) => onUpdate(index, "weight", parseFloat(e.target.value) || 0)}
className="h-8 w-24 text-sm"
data-testid={`routing-target-${index}-weight-input`}
/>
</div>
{showRemove && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="h-8 w-8 p-0"
aria-label={`Remove target ${index + 1}`}
data-testid={`routing-target-${index}-remove-button`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label id={`routing-target-${index}-provider-label`} className="text-xs">
Provider
</Label>
<div className="flex gap-1.5">
<Select
value={target.provider}
onValueChange={(value) => {
onUpdate(index, "provider", value);
onUpdate(index, "model", "");
onUpdate(index, "key_id", "");
}}
>
<SelectTrigger
id={`routing-target-${index}-provider-select`}
aria-labelledby={`routing-target-${index}-provider-label`}
className="h-9 flex-1 text-sm"
data-testid={`routing-target-${index}-provider-select`}
>
<SelectValue placeholder="Incoming (optional)" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((prov) => (
<SelectItem key={prov} value={prov}>
<div className="flex items-center gap-2">
<RenderProviderIcon provider={prov as ProviderIconType} size="sm" className="h-4 w-4" />
<span>{getProviderLabel(prov)}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{target.provider && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onUpdate(index, "provider", "");
onUpdate(index, "model", "");
onUpdate(index, "key_id", "");
}}
className="h-9 w-9 p-0"
aria-label={`Clear provider for target ${index + 1}`}
data-testid={`routing-target-${index}-provider-clear`}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1.5">
<Label id={`routing-target-${index}-model-label`} className="text-xs">
Model
</Label>
<div className="flex gap-1.5">
<div className="flex-1" data-testid={`routing-target-${index}-model-select`}>
<ModelMultiselect
provider={target.provider || undefined}
value={target.model}
onChange={(value) => onUpdate(index, "model", value)}
placeholder="Incoming (optional)"
isSingleSelect
loadModelsOnEmptyProvider
className="!h-9 !min-h-9"
inputId={`routing-target-${index}-model-input`}
ariaLabelledBy={`routing-target-${index}-model-label`}
/>
</div>
{target.model && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onUpdate(index, "model", "")}
className="h-9 w-9 p-0"
aria-label={`Clear model for target ${index + 1}`}
data-testid={`routing-target-${index}-model-clear`}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
{target.provider && (availableKeys.length > 0 || target.key_id) && (
<div className="space-y-1.5">
<Label id={`routing-target-${index}-apikey-label`} className="text-xs">
API Key <span className="text-muted-foreground">(optional leave unset for load-balanced selection)</span>
</Label>
<div className="flex gap-1.5">
<Select value={target.key_id || ""} onValueChange={(value) => onUpdate(index, "key_id", value)}>
<SelectTrigger
id={`routing-target-${index}-apikey-select`}
aria-labelledby={`routing-target-${index}-apikey-label`}
className="h-9 flex-1 text-sm"
data-testid={`routing-target-${index}-apikey-select`}
>
<SelectValue placeholder="Select key (optional)" />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.name}
</SelectItem>
))}
{target.key_id && !availableKeys.some((k) => k.id === target.key_id) && (
<SelectItem key={`pinned-${target.key_id}`} value={target.key_id}>
(pinned) {target.key_id}
</SelectItem>
)}
</SelectContent>
</Select>
{target.key_id && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onUpdate(index, "key_id", "")}
className="h-9 w-9 p-0"
aria-label={`Clear API key for target ${index + 1}`}
data-testid={`routing-target-${index}-apikey-clear`}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { Button } from "@/components/ui/button";
import { Route } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
const ROUTING_RULES_DOCS_URL = "https://docs.getbifrost.ai/providers/routing-rules";
interface RoutingRulesEmptyStateProps {
onAddClick: () => void;
canCreate?: boolean;
}
export function RoutingRulesEmptyState({ onAddClick, canCreate = true }: RoutingRulesEmptyStateProps) {
return (
<div
className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center"
data-testid="routing-rules-empty-state"
>
<div className="text-muted-foreground">
<Route className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">Routing rules direct requests using CEL conditions</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
Create CEL-based rules to route requests by model, provider, budget, or custom attributes. Control which provider or model handles
each request.
</div>
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
aria-label="Read more about routing rules (opens in new tab)"
data-testid="routing-rules-empty-read-more"
onClick={() => {
window.open(`${ROUTING_RULES_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
}}
>
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
</Button>
<Button
aria-label="Create your first routing rule"
data-testid="create-routing-rule-btn"
onClick={onAddClick}
disabled={!canCreate}
>
New Rule
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
/**
* 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 &quot;{ruleToDelete?.name}&quot;? 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>
);
}

View File

@@ -0,0 +1,139 @@
/**
* Routing Rules View
* Main orchestrator component for routing rules management
*/
import { RbacOperation, RbacResource, useRbac } from "@/app/_fallbacks/enterprise/lib/contexts/rbacContext";
import { Button } from "@/components/ui/button";
import { useDebouncedValue } from "@/hooks/useDebounce";
import { useGetRoutingRulesQuery } from "@/lib/store/apis/routingRulesApi";
import { RoutingRule } from "@/lib/types/routingRules";
import { GitBranch, Plus } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { RoutingRuleInfoSheet } from "./routingRuleInfoSheet";
import { RoutingRuleSheet } from "./routingRuleSheet";
import { RoutingRulesEmptyState } from "./routingRulesEmptyState";
import { RoutingRulesTable } from "./routingRulesTable";
const POLLING_INTERVAL = 5000;
const PAGE_SIZE = 25;
export function RoutingRulesView() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
const [selectedRule, setSelectedRule] = useState<RoutingRule | null>(null);
const [search, setSearch] = useState("");
const [offset, setOffset] = useState(0);
const debouncedSearch = useDebouncedValue(search, 300);
// Reset to first page when search changes
useEffect(() => {
setOffset(0);
}, [debouncedSearch]);
// Permissions
const canCreate = useRbac(RbacResource.RoutingRules, RbacOperation.Create);
const canDelete = useRbac(RbacResource.RoutingRules, RbacOperation.Delete);
// API
const { data: rulesData, isLoading } = useGetRoutingRulesQuery(
{
limit: PAGE_SIZE,
offset,
search: debouncedSearch || undefined,
},
{
pollingInterval: POLLING_INTERVAL,
},
);
const rules = rulesData?.rules || [];
const totalCount = rulesData?.total_count || 0;
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
useEffect(() => {
if (!rulesData || offset < totalCount) return;
setOffset(totalCount === 0 ? 0 : Math.floor((totalCount - 1) / PAGE_SIZE) * PAGE_SIZE);
}, [totalCount, offset]);
const handleCreateNew = () => {
setEditingRule(null);
setDialogOpen(true);
};
const handleEdit = (rule: RoutingRule) => {
setEditingRule(rule);
setDialogOpen(true);
};
const handleRowClick = (rule: RoutingRule) => {
setSelectedRule(rule);
setInfoSheetOpen(true);
};
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
if (!open) {
setEditingRule(null);
}
};
const hasActiveFilters = debouncedSearch;
// True empty state: no rules at all (not just filtered to zero)
if (!isLoading && totalCount === 0 && !hasActiveFilters) {
return (
<>
<RoutingRulesEmptyState onAddClick={handleCreateNew} canCreate={canCreate} />
<RoutingRuleSheet open={dialogOpen} onOpenChange={handleDialogOpenChange} editingRule={editingRule} />
</>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-foreground text-lg font-semibold">Routing Rules</h1>
<p className="text-muted-foreground text-sm">Manage CEL-based routing rules for intelligent request routing across providers</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to="/workspace/routing-rules/tree">
<GitBranch className="h-4 w-4" />
<span className="hidden sm:inline">View Tree</span>
</Link>
</Button>
{canCreate && (
<Button data-testid="create-routing-rule-btn" onClick={handleCreateNew} disabled={isLoading} className="gap-2">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New Rule</span>
</Button>
)}
</div>
</div>
<RoutingRulesTable
rules={rules}
totalCount={totalCount}
isLoading={isLoading}
onEdit={handleEdit}
onRowClick={handleRowClick}
canDelete={canDelete}
search={search}
onSearchChange={setSearch}
offset={offset}
limit={PAGE_SIZE}
onOffsetChange={setOffset}
/>
<RoutingRuleSheet open={dialogOpen} onOpenChange={handleDialogOpenChange} editingRule={editingRule} />
<RoutingRuleInfoSheet rule={selectedRule} open={infoSheetOpen} onOpenChange={setInfoSheetOpen} />
</div>
);
}