This commit is contained in:
Ishaan Jaffer 2026-03-02 19:05:56 -08:00
parent b5b0b77e0e
commit f39adbc50b
5 changed files with 1049 additions and 666 deletions

View file

@ -1,150 +0,0 @@
"use client";
import React, { useEffect, useMemo } from "react";
import { Select } from "antd";
import { RobotOutlined } from "@ant-design/icons";
import { TextInput } from "@tremor/react";
import { useRef, useState } from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
export interface DeploymentOption {
model_id: string;
model_name: string;
provider: string;
}
interface DeploymentSelectorProps {
value?: string | null;
placeholder?: string;
onChange?: (deployment: DeploymentOption | null) => void;
disabled?: boolean;
style?: React.CSSProperties;
className?: string;
showLabel?: boolean;
labelText?: string;
excludeIds?: Set<string>;
}
export default function DeploymentSelector({
value,
placeholder = "Select a deployment",
onChange,
disabled = false,
style,
className,
showLabel = true,
labelText = "Select Model",
excludeIds = new Set(),
}: DeploymentSelectorProps) {
const { accessToken, userId, userRole } = useAuthorized();
const { data: rawModelData } = useModelsInfo(1, 200);
const { data: modelCostMapData } = useModelCostMap();
const [selectedValue, setSelectedValue] = useState<string | undefined>(value ?? undefined);
const [showCustomModelInput, setShowCustomModelInput] = useState(false);
const customModelTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setSelectedValue(value ?? undefined);
}, [value]);
const getProviderFromModel = (model: string) => {
if (modelCostMapData && typeof modelCostMapData === "object" && model in modelCostMapData) {
return modelCostMapData[model]?.litellm_provider ?? "openai";
}
return "openai";
};
const modelData = useMemo(() => {
if (!rawModelData?.data) return { data: [] };
return transformModelData(rawModelData, getProviderFromModel);
}, [rawModelData?.data, modelCostMapData]);
const deploymentOptions = useMemo(() => {
if (!modelData?.data) return [];
return modelData.data
.filter((m: any) => m.model_info?.id && !excludeIds.has(m.model_info.id))
.map((m: any) => ({
model_id: m.model_info.id,
model_name: m.model_name,
provider: m.provider || "-",
label: `${m.provider || "Unknown"} / ${m.model_name}`,
}));
}, [modelData?.data, excludeIds]);
const options = [
{ value: "__custom__", label: "Enter custom model", key: "custom" },
...deploymentOptions.map((d) => ({
value: d.model_id,
label: d.label,
key: d.model_id,
})),
];
const handleChange = (val: string) => {
if (val === "__custom__") {
setShowCustomModelInput(true);
setSelectedValue(undefined);
onChange?.(null);
} else {
setShowCustomModelInput(false);
setSelectedValue(val);
const dep = deploymentOptions.find((d) => d.model_id === val);
if (dep) {
onChange?.({ model_id: dep.model_id, model_name: dep.model_name, provider: dep.provider });
}
}
};
const isCustomValue = !!value && !deploymentOptions.some((d) => d.model_id === value);
const handleCustomModelChange = (inputVal: string) => {
if (customModelTimeout.current) clearTimeout(customModelTimeout.current);
customModelTimeout.current = setTimeout(() => {
if (inputVal.trim()) {
setSelectedValue(`custom:${inputVal}`);
onChange?.({
model_id: inputVal.trim(),
model_name: inputVal.trim(),
provider: "custom",
});
} else {
onChange?.(null);
}
}, 500);
};
return (
<div>
{showLabel && (
<div className="font-medium mb-2 text-gray-700 flex items-center">
<RobotOutlined className="mr-2" /> {labelText}
</div>
)}
<Select
value={showCustomModelInput ? undefined : selectedValue}
placeholder={placeholder}
onChange={handleChange}
options={options}
style={{ width: "100%", ...style }}
showSearch
className={`rounded-md ${className || ""}`}
disabled={disabled}
filterOption={(input, option) =>
(option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())
}
/>
{(showCustomModelInput || isCustomValue) && (
<TextInput
className="mt-2"
placeholder="Enter custom model name (e.g. openai/gpt-4)"
defaultValue={isCustomValue ? value : undefined}
onValueChange={handleCustomModelChange}
disabled={disabled}
/>
)}
</div>
);
}

View file

@ -0,0 +1,350 @@
"use client";
import React from "react";
interface Deployment {
model_id: string;
model_name: string;
provider: string;
weight?: number;
priority?: number;
}
interface RoutingFlowVisualizationProps {
strategy: string;
deployments: Deployment[];
}
const DEPLOY_COLORS = ["#10b981", "#f59e0b", "#3b82f6", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
const VerticalLine: React.FC<{ height?: number }> = ({ height = 28 }) => (
<div className="flex justify-center" style={{ height }}>
<div style={{ width: 2, height: "100%", backgroundColor: "#e5e7eb" }} />
</div>
);
const StatusDot: React.FC = () => (
<div style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "#22c55e", flexShrink: 0 }} />
);
const KillTag: React.FC = () => (
<span style={{ fontSize: 10, fontWeight: 600, color: "#ef4444", backgroundColor: "#fef2f2", padding: "2px 8px", borderRadius: 4, flexShrink: 0 }}>
kill
</span>
);
const FailConnector: React.FC = () => (
<div className="flex flex-col items-center" style={{ margin: "4px 0" }}>
<VerticalLine height={12} />
<div className="flex items-center gap-1.5" style={{ padding: "3px 10px", backgroundColor: "#fef2f2", borderRadius: 12 }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
<span style={{ fontSize: 10, fontWeight: 700, color: "#ef4444", letterSpacing: "0.04em" }}>
FAIL → TRY NEXT
</span>
</div>
<VerticalLine height={12} />
</div>
);
const ProviderIcon: React.FC<{ color: string; name: string }> = ({ color, name }) => (
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: color + "18",
border: `1.5px solid ${color}40`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: 13,
fontWeight: 700,
color: color,
}}
>
{name.charAt(0).toUpperCase()}
</div>
);
const TriggerCard: React.FC<{ subtitle: string }> = ({ subtitle }) => (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "16px 20px",
backgroundColor: "#fff",
width: "100%",
maxWidth: 340,
}}
>
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>
TRIGGER
</div>
<div className="flex items-center gap-2">
<div style={{ width: 28, height: 28, borderRadius: "50%", backgroundColor: "#f3f4f6", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="#6b7280" stroke="none"><polygon points="6,3 20,12 6,21" /></svg>
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: "#111827" }}>Incoming Request</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{subtitle}</div>
</div>
</div>
</div>
);
const OutputCard: React.FC<{ subtitle: string }> = ({ subtitle }) => (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "16px 20px",
backgroundColor: "#fff",
width: "100%",
maxWidth: 340,
}}
>
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>
OUTPUT
</div>
<div className="flex items-center gap-2">
<div style={{ width: 28, height: 28, borderRadius: "50%", backgroundColor: "#f0fdf4", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><path d="M9 12l2 2 4-4" />
</svg>
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: "#111827" }}>Response</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{subtitle}</div>
</div>
</div>
</div>
);
const ModelCard: React.FC<{
label: string;
name: string;
subName: string;
color: string;
}> = ({ label, name, subName, color }) => (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "14px 18px",
backgroundColor: "#fff",
width: "100%",
maxWidth: 340,
position: "relative",
}}
>
<div style={{ position: "absolute", top: 12, right: 14 }}><StatusDot /></div>
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>
{label}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<ProviderIcon color={color} name={name} />
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>{name}</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{subName}</div>
</div>
</div>
<KillTag />
</div>
</div>
);
const FailoverFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }) => (
<div className="flex flex-col items-center">
<TriggerCard subtitle="→ first available" />
{deployments.map((dep, i) => {
const label = i === 0 ? "PRIMARY" : `FALLBACK-${i}`;
const shortName = dep.model_name.split("/").pop() || dep.model_name;
return (
<React.Fragment key={dep.model_id + i}>
{i === 0 ? <VerticalLine /> : <FailConnector />}
<ModelCard
label={label}
name={shortName}
subName={dep.model_name}
color={DEPLOY_COLORS[i % DEPLOY_COLORS.length]}
/>
</React.Fragment>
);
})}
<VerticalLine />
<OutputCard subtitle="first successful result" />
</div>
);
const WeightedFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }) => {
const totalWeight = deployments.reduce((s, d) => s + (d.weight ?? 1), 0);
return (
<div className="flex flex-col items-center">
<TriggerCard subtitle="→ weighted distribution" />
<VerticalLine />
{/* Load balancer split node */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "14px 18px",
backgroundColor: "#fff",
width: "100%",
maxWidth: 340,
}}
>
<div className="flex items-center gap-2" style={{ marginBottom: 10 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
</svg>
<span style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em" }}>
LOAD BALANCER
</span>
</div>
{/* Weight bar */}
<div style={{ width: "100%", height: 8, borderRadius: 4, overflow: "hidden", display: "flex", backgroundColor: "#f3f4f6", marginBottom: 4 }}>
{deployments.map((dep, i) => {
const pct = totalWeight > 0 ? ((dep.weight ?? 1) / totalWeight) * 100 : 0;
return (
<div key={dep.model_id + i} style={{ width: `${pct}%`, height: "100%", backgroundColor: DEPLOY_COLORS[i % DEPLOY_COLORS.length] }} />
);
})}
</div>
</div>
{/* Fan-out lines from center */}
<div style={{ position: "relative", width: "100%", maxWidth: 500 }}>
{/* Vertical stem */}
<div className="flex justify-center"><VerticalLine height={16} /></div>
{/* Horizontal bar */}
<div style={{ position: "relative", marginLeft: "10%", marginRight: "10%", height: 2, backgroundColor: "#e5e7eb" }} />
{/* Branch lines + cards */}
<div
style={{
display: "flex",
gap: 12,
width: "100%",
paddingLeft: "2%",
paddingRight: "2%",
}}
>
{deployments.map((dep, i) => {
const pct = totalWeight > 0 ? Math.round(((dep.weight ?? 1) / totalWeight) * 100) : 0;
const shortName = dep.model_name.split("/").pop() || dep.model_name;
return (
<div key={dep.model_id + i} className="flex flex-col items-center flex-1" style={{ minWidth: 0 }}>
<VerticalLine height={16} />
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "10px 8px",
backgroundColor: "#fff",
width: "100%",
textAlign: "center",
position: "relative",
}}
>
<div style={{ position: "absolute", top: 6, right: 6 }}><StatusDot /></div>
<div style={{ fontSize: 10, fontWeight: 700, color: DEPLOY_COLORS[i % DEPLOY_COLORS.length], marginBottom: 2 }}>
{pct}%
</div>
<ProviderIcon color={DEPLOY_COLORS[i % DEPLOY_COLORS.length]} name={shortName} />
<div style={{ fontSize: 11, fontWeight: 600, color: "#111827", marginTop: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={dep.model_name}>
{shortName}
</div>
<div style={{ fontSize: 10, color: "#9ca3af", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{dep.model_name}
</div>
</div>
<VerticalLine height={16} />
</div>
);
})}
</div>
{/* Fan-in horizontal bar */}
<div style={{ position: "relative", marginLeft: "10%", marginRight: "10%", height: 2, backgroundColor: "#e5e7eb" }} />
<div className="flex justify-center"><VerticalLine height={16} /></div>
</div>
<OutputCard subtitle="weighted distribution" />
</div>
);
};
const SmartRoutingFlow: React.FC<{ deployments: Deployment[]; strategy: string }> = ({ deployments, strategy }) => {
const isLatency = strategy === "latency-based-routing";
return (
<div className="flex flex-col items-center">
<TriggerCard subtitle={isLatency ? "→ latency-based" : "→ usage-based"} />
<VerticalLine />
<div
style={{
border: "1px solid #8b5cf6",
borderRadius: 12,
padding: "14px 18px",
backgroundColor: "#faf5ff",
width: "100%",
maxWidth: 340,
}}
>
<div style={{ fontSize: 10, fontWeight: 700, color: "#8b5cf6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 8 }}>
{isLatency ? "LATENCY ROUTER" : "USAGE ROUTER"}
</div>
<div className="flex flex-col gap-2">
{deployments.map((dep, i) => {
const shortName = dep.model_name.split("/").pop() || dep.model_name;
return (
<div key={dep.model_id + i} className="flex items-center justify-between" style={{ padding: "6px 10px", backgroundColor: "#fff", borderRadius: 8, border: "1px solid #e5e7eb" }}>
<div className="flex items-center gap-2">
<ProviderIcon color={DEPLOY_COLORS[i % DEPLOY_COLORS.length]} name={shortName} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>{shortName}</div>
<div style={{ fontSize: 10, color: "#9ca3af" }}>{dep.model_name}</div>
</div>
</div>
<StatusDot />
</div>
);
})}
</div>
</div>
<VerticalLine />
<OutputCard subtitle={isLatency ? "fastest model wins" : "least busy model selected"} />
</div>
);
};
const EmptyFlow: React.FC = () => (
<div className="flex flex-col items-center">
<TriggerCard subtitle="→ select a strategy" />
<VerticalLine />
<div style={{ border: "1px dashed #d1d5db", borderRadius: 12, padding: "28px 20px", width: "100%", maxWidth: 340, textAlign: "center" }}>
<span style={{ fontSize: 13, color: "#9ca3af" }}>Add deployments to see the flow</span>
</div>
<VerticalLine />
<OutputCard subtitle="waiting for configuration" />
</div>
);
export default function RoutingFlowVisualization({ strategy, deployments }: RoutingFlowVisualizationProps) {
if (deployments.length === 0) return <EmptyFlow />;
switch (strategy) {
case "priority-failover": return <FailoverFlow deployments={deployments} />;
case "weighted": return <WeightedFlow deployments={deployments} />;
case "latency-based-routing":
case "usage-based-routing-v2": return <SmartRoutingFlow deployments={deployments} strategy={strategy} />;
default: return <FailoverFlow deployments={deployments} />;
}
}
export { DEPLOY_COLORS };

View file

@ -1,15 +1,12 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Button, Checkbox, Input, InputNumber, Typography } from "antd";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, InputNumber } from "antd";
import {
ArrowLeftOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
BranchesOutlined,
CloudOutlined,
CodeOutlined,
DeleteOutlined,
HolderOutlined,
OrderedListOutlined,
PlusOutlined,
PercentageOutlined,
@ -17,7 +14,9 @@ import {
DashboardOutlined,
} from "@ant-design/icons";
import { routingGroupCreateCall, routingGroupUpdateCall } from "@/components/networking";
import DeploymentSelector, { DeploymentOption } from "./DeploymentSelector";
import ModelSelector from "@/components/common_components/ModelSelector";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import RoutingFlowVisualization, { DEPLOY_COLORS } from "./RoutingFlowVisualization";
interface Deployment {
model_id: string;
@ -25,7 +24,6 @@ interface Deployment {
provider: string;
weight?: number;
priority?: number;
enabled?: boolean;
}
interface RoutingGroupBuilderProps {
@ -35,71 +33,52 @@ interface RoutingGroupBuilderProps {
onSuccess: () => void;
}
const PROVIDER_COLORS: Record<string, string> = {
"Nebius": "#10b981",
"Fireworks AI": "#ef4444",
"Azure": "#3b82f6",
"Azure OpenAI": "#3b82f6",
"OpenAI": "#10b981",
"Anthropic": "#f59e0b",
"Google AI Studio": "#4285f4",
"Groq": "#8b5cf6",
"Deepseek": "#06b6d4",
"Mistral AI": "#f97316",
"Amazon Bedrock": "#f59e0b",
"custom": "#6b7280",
};
function getProviderColor(provider: string): string {
return PROVIDER_COLORS[provider] ?? "#6366f1";
}
function getProviderInitial(provider: string): string {
if (provider === "-" || !provider) return "?";
return provider.charAt(0).toUpperCase();
}
const STRATEGIES = [
{
value: "priority-failover",
label: "Ordered Fallback",
desc: "Try in priority order, skip on failure",
icon: <OrderedListOutlined style={{ fontSize: 24 }} />,
icon: <OrderedListOutlined style={{ fontSize: 20 }} />,
},
{
value: "weighted",
label: "Weighted Round-Robin",
desc: "Distribute traffic by weight %",
icon: <PercentageOutlined style={{ fontSize: 24 }} />,
icon: <PercentageOutlined style={{ fontSize: 20 }} />,
},
{
value: "usage-based-routing-v2",
label: "Usage-Based",
desc: "Route to least-loaded deployment",
icon: <DashboardOutlined style={{ fontSize: 24 }} />,
icon: <DashboardOutlined style={{ fontSize: 20 }} />,
},
{
value: "latency-based-routing",
label: "Latency-Based",
desc: "Route to fastest responding backend",
icon: <ThunderboltOutlined style={{ fontSize: 24 }} />,
icon: <ThunderboltOutlined style={{ fontSize: 20 }} />,
},
];
const INITIAL_SLOT_COUNT = 3;
export default function RoutingGroupBuilder({
accessToken,
editTarget,
onClose,
onSuccess,
}: RoutingGroupBuilderProps) {
const { accessToken: authToken } = useAuthorized();
const effectiveToken = accessToken ?? authToken ?? "";
const [strategy, setStrategy] = useState("priority-failover");
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [emptySlots, setEmptySlots] = useState(INITIAL_SLOT_COUNT);
const [logicalName, setLogicalName] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [showAddSelector, setShowAddSelector] = useState(false);
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentOption | null>(null);
const [selectorKey, setSelectorKey] = useState(0);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const isEditing = editTarget !== null;
@ -107,22 +86,25 @@ export default function RoutingGroupBuilder({
if (editTarget) {
setStrategy((editTarget.routing_strategy as string) ?? "priority-failover");
setLogicalName((editTarget.routing_group_name as string) ?? "");
setDescription((editTarget.description as string) ?? "");
if (Array.isArray(editTarget.deployments)) {
setDeployments(
(editTarget.deployments as any[]).map((d, i) => ({
model_id: d.model_id ?? d.model_name,
model_name: d.model_name,
provider: d.provider ?? d.litellm_provider ?? "-",
weight: d.weight,
priority: d.priority ?? i + 1,
enabled: true,
}))
);
const deps = (editTarget.deployments as any[]).map((d, i) => ({
model_id: d.model_id ?? d.model_name,
model_name: d.model_name,
provider: d.provider ?? d.litellm_provider ?? "-",
weight: d.weight,
priority: d.priority ?? i + 1,
}));
setDeployments(deps);
setEmptySlots(Math.max(0, INITIAL_SLOT_COUNT - deps.length));
}
}
}, [editTarget]);
const totalWeight = useMemo(
() => deployments.reduce((s, d) => s + (d.weight ?? 1), 0),
[deployments]
);
const handleStrategyChange = (value: string) => {
setStrategy(value);
setDeployments((prev) =>
@ -134,24 +116,31 @@ export default function RoutingGroupBuilder({
);
};
const addDeployment = () => {
if (!selectedDeployment) return;
setDeployments((prev) => [
...prev,
{
model_id: selectedDeployment.model_id,
model_name: selectedDeployment.model_name,
provider: selectedDeployment.provider,
weight: strategy === "weighted" ? 1 : undefined,
priority: strategy === "priority-failover" ? prev.length + 1 : undefined,
enabled: true,
},
]);
setSelectedDeployment(null);
setSelectorKey((k) => k + 1);
setShowAddSelector(false);
const handleSlotSelected = (slotIndex: number, modelGroup: string) => {
if (!modelGroup || deployments.some((d) => d.model_name === modelGroup)) return;
const newDep: Deployment = {
model_id: modelGroup,
model_name: modelGroup,
provider: "-",
weight: strategy === "weighted" ? 1 : undefined,
priority: strategy === "priority-failover" ? deployments.length + 1 : undefined,
};
setDeployments((prev) => [...prev, newDep]);
setEmptySlots((prev) => Math.max(0, prev - 1));
};
const handleDeploymentChange = (index: number, modelGroup: string) => {
if (!modelGroup) return;
if (deployments.some((d, i) => i !== index && d.model_name === modelGroup)) return;
setDeployments((prev) =>
prev.map((d, i) =>
i === index ? { ...d, model_id: modelGroup, model_name: modelGroup } : d
)
);
};
const addEmptySlot = () => setEmptySlots((prev) => prev + 1);
const removeDeployment = (index: number) => {
setDeployments((prev) => {
const updated = prev.filter((_, i) => i !== index);
@ -162,29 +151,32 @@ export default function RoutingGroupBuilder({
});
};
const moveDeployment = (index: number, direction: "up" | "down") => {
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= deployments.length) return;
setDeployments((prev) => {
const updated = [...prev];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
if (strategy === "priority-failover") {
return updated.map((d, i) => ({ ...d, priority: i + 1 }));
}
return updated;
});
};
const updateWeight = (index: number, weight: number | null) => {
setDeployments((prev) =>
prev.map((d, i) => (i === index ? { ...d, weight: weight ?? 1 } : d))
);
};
const toggleEnabled = (index: number) => {
setDeployments((prev) =>
prev.map((d, i) => (i === index ? { ...d, enabled: !d.enabled } : d))
);
const handleDragStart = (index: number) => { dragItem.current = index; };
const handleDragEnter = (index: number) => { dragOverItem.current = index; };
const handleDragEnd = () => {
if (dragItem.current === null || dragOverItem.current === null) return;
if (dragItem.current === dragOverItem.current) {
dragItem.current = null;
dragOverItem.current = null;
return;
}
setDeployments((prev) => {
const updated = [...prev];
const [removed] = updated.splice(dragItem.current!, 1);
updated.splice(dragOverItem.current!, 0, removed);
if (strategy === "priority-failover") {
return updated.map((d, i) => ({ ...d, priority: i + 1 }));
}
return updated;
});
dragItem.current = null;
dragOverItem.current = null;
};
const handleSubmit = async () => {
@ -193,9 +185,8 @@ export default function RoutingGroupBuilder({
setSubmitting(true);
const payload: Record<string, unknown> = {
routing_group_name: logicalName.trim(),
description: description.trim() || undefined,
routing_strategy: strategy,
deployments: deployments.filter((d) => d.enabled !== false),
deployments,
};
if (isEditing && editTarget?.routing_group_id) {
await routingGroupUpdateCall(accessToken, editTarget.routing_group_id as string, payload);
@ -210,255 +201,283 @@ export default function RoutingGroupBuilder({
}
};
const excludeIds = useMemo(() => new Set(deployments.map((d) => d.model_id)), [deployments]);
const codeSnippet = `# invoke this group:\nclient.chat.completions.create(\n model="${logicalName || "your-model-name"}",\n messages=[...]\n)`;
return (
<div className="w-full max-w-2xl mx-auto py-6 px-4">
{/* Header */}
<div className="mb-1">
<button
onClick={onClose}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-3 bg-transparent border-none cursor-pointer p-0"
>
<ArrowLeftOutlined /> Back to Routing Groups
</button>
<h1 className="text-xl font-bold text-gray-900 m-0">Routing Group Builder</h1>
<p className="text-sm text-gray-500 mt-1 mb-0">
Configure a named model group with a routing strategy
</p>
</div>
{/* Section 1: Routing Strategy */}
<div className="mt-8">
<div className="text-xs font-semibold text-gray-500 tracking-wide mb-4">
1 &middot; ROUTING STRATEGY
</div>
<div className="grid grid-cols-2 gap-3">
{STRATEGIES.map((s) => {
const isActive = strategy === s.value;
return (
<button
key={s.value}
onClick={() => handleStrategyChange(s.value)}
className="text-left p-4 rounded-xl border-2 transition-all bg-white cursor-pointer"
style={{
borderColor: isActive ? "#3b82f6" : "#e5e7eb",
boxShadow: isActive ? "0 0 0 1px #3b82f6" : "none",
}}
>
<div className="mb-2 text-gray-400">{s.icon}</div>
<div className="font-semibold text-gray-900 text-sm">{s.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div>
{isActive && (
<span className="inline-block mt-2 text-xs font-semibold text-blue-600 border border-blue-600 rounded px-1.5 py-0.5">
Active
</span>
)}
</button>
);
})}
</div>
</div>
{/* Section 2: Deployments */}
<div className="mt-10">
<div className="flex items-center justify-between mb-4">
<div className="text-xs font-semibold text-gray-500 tracking-wide">
2 &middot; DEPLOYMENTS
</div>
<div className="w-full max-w-screen overflow-x-hidden box-border">
<div className="flex" style={{ minHeight: "100vh" }}>
{/* ── LEFT: Config Panel ── */}
<div style={{ width: 380, flexShrink: 0, borderRight: "1px solid #e5e7eb", backgroundColor: "#fff", overflowY: "auto", padding: "20px 20px 48px" }}>
<button
onClick={() => setShowAddSelector(!showAddSelector)}
className="flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 bg-transparent border-none cursor-pointer p-0"
onClick={onClose}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-3 bg-transparent border-none cursor-pointer p-0"
>
<PlusOutlined /> Add
<ArrowLeftOutlined /> Back to Routing Groups
</button>
</div>
<h1 className="text-lg font-semibold m-0">Routing Group Builder</h1>
<p className="text-xs text-gray-500 mt-1 mb-5">Configure a named model group with a routing strategy</p>
{/* Add selector */}
{showAddSelector && (
<div className="mb-4 p-3 rounded-lg border border-blue-200 bg-blue-50">
<div className="flex items-end gap-2">
<div className="flex-1">
<DeploymentSelector
key={selectorKey}
value={selectedDeployment?.model_id ?? null}
placeholder="Search and select a model..."
showLabel={false}
excludeIds={excludeIds}
onChange={(dep) => setSelectedDeployment(dep)}
/>
</div>
<Button
type="primary"
size="small"
disabled={!selectedDeployment}
onClick={addDeployment}
>
Add
</Button>
<Button
size="small"
onClick={() => { setShowAddSelector(false); setSelectedDeployment(null); }}
>
Cancel
</Button>
{/* 1 · Routing Strategy */}
<div className="mb-5">
<div className="text-xs font-semibold text-gray-400 tracking-wide uppercase mb-3">1 &middot; Routing Strategy</div>
<div className="grid grid-cols-2 gap-2">
{STRATEGIES.map((s) => {
const isActive = strategy === s.value;
return (
<button
key={s.value}
onClick={() => handleStrategyChange(s.value)}
className="text-left p-3 rounded-lg border-2 transition-all bg-white cursor-pointer"
style={{ borderColor: isActive ? "#3b82f6" : "#e5e7eb", boxShadow: isActive ? "0 0 0 1px #3b82f6" : "none" }}
>
<div className="mb-1.5 text-gray-400">{s.icon}</div>
<div className="font-semibold text-gray-900 text-xs">{s.label}</div>
<div className="text-xs text-gray-500 mt-0.5 leading-tight">{s.desc}</div>
{isActive && (
<span className="inline-block mt-1.5 text-xs font-semibold text-blue-600 bg-blue-50 rounded px-1.5 py-0.5">
Active
</span>
)}
</button>
);
})}
</div>
</div>
)}
{/* Pipeline visualization */}
{deployments.length > 0 && (
<div className="flex items-center gap-1 mb-4 flex-wrap">
{deployments.map((dep, idx) => {
const color = getProviderColor(dep.provider);
return (
<React.Fragment key={dep.model_id}>
{idx > 0 && (
<span className="text-gray-300 text-xs mx-0.5">&rarr;</span>
)}
<span
className="inline-flex items-center gap-1 text-xs font-medium rounded-full px-2.5 py-1 border"
style={{
borderColor: color + "44",
color: color,
backgroundColor: color + "0d",
}}
>
<span className="font-bold">{idx + 1}</span>
{dep.provider !== "-" ? dep.provider.split(" ")[0] : dep.model_name}
</span>
</React.Fragment>
);
})}
</div>
)}
{/* Deployment cards */}
{deployments.length === 0 && !showAddSelector && (
<div className="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-200 rounded-xl">
No deployments yet. Click <strong>+ Add</strong> to get started.
</div>
)}
<div className="flex flex-col gap-2">
{deployments.map((dep, idx) => {
const color = getProviderColor(dep.provider);
return (
<div
key={dep.model_id}
className="flex items-center gap-3 rounded-xl border bg-white px-4 py-3 transition-all"
style={{ borderLeft: `4px solid ${color}` }}
{/* 2 · Deployments */}
<div className="mb-5">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-gray-400 tracking-wide uppercase">2 &middot; Deployments</div>
<button
onClick={addEmptySlot}
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700 bg-transparent border-none cursor-pointer p-0 font-medium"
>
<Checkbox
checked={dep.enabled !== false}
onChange={() => toggleEnabled(idx)}
/>
<span className="text-xs font-bold text-gray-400 min-w-[16px]">
{idx + 1}
</span>
{/* Provider icon */}
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-bold"
style={{ backgroundColor: color + "1a", color }}
>
{getProviderInitial(dep.provider)}
</div>
{/* Name */}
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-gray-800">
{dep.provider}
</span>
<span className="text-sm text-gray-500 ml-1.5">
/ {dep.model_name}
</span>
</div>
{/* Weight (only for weighted) */}
{strategy === "weighted" && (
<InputNumber
size="small"
min={1}
max={100}
value={dep.weight ?? 1}
onChange={(val) => updateWeight(idx, val)}
addonAfter="%"
style={{ width: 90 }}
/>
)}
{/* Reorder */}
<div className="flex flex-col gap-0">
<button
onClick={() => moveDeployment(idx, "up")}
disabled={idx === 0}
className="text-gray-300 hover:text-gray-500 disabled:opacity-30 bg-transparent border-none cursor-pointer p-0 leading-none"
>
<ArrowUpOutlined style={{ fontSize: 12 }} />
</button>
<button
onClick={() => moveDeployment(idx, "down")}
disabled={idx === deployments.length - 1}
className="text-gray-300 hover:text-gray-500 disabled:opacity-30 bg-transparent border-none cursor-pointer p-0 leading-none"
>
<ArrowDownOutlined style={{ fontSize: 12 }} />
</button>
</div>
{/* Delete */}
<button
onClick={() => removeDeployment(idx)}
className="text-gray-300 hover:text-red-400 bg-transparent border-none cursor-pointer p-0"
>
<DeleteOutlined style={{ fontSize: 14 }} />
</button>
<PlusOutlined /> Add
</button>
</div>
{/* Pipeline breadcrumb chain */}
{deployments.length > 0 && (
<div className="flex items-center flex-wrap gap-1 mb-3">
{deployments.map((dep, i) => {
const shortName = dep.model_name.split("/").pop() || dep.model_name;
return (
<React.Fragment key={dep.model_id + i}>
{i > 0 && <span style={{ fontSize: 10, color: "#9ca3af" }}>→</span>}
<span
style={{
fontSize: 11,
fontWeight: 600,
color: DEPLOY_COLORS[i % DEPLOY_COLORS.length],
backgroundColor: DEPLOY_COLORS[i % DEPLOY_COLORS.length] + "14",
border: `1px solid ${DEPLOY_COLORS[i % DEPLOY_COLORS.length]}30`,
padding: "2px 8px",
borderRadius: 6,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<span style={{ fontWeight: 700, fontSize: 10 }}>{i + 1}</span>
{shortName}
</span>
</React.Fragment>
);
})}
</div>
);
})}
</div>
</div>
)}
{/* Section 3: Logical Model Name */}
<div className="mt-10">
<div className="text-xs font-semibold text-gray-500 tracking-wide mb-1">
3 &middot; LOGICAL MODEL NAME
</div>
<div className="text-xs text-gray-400 mb-3">
What callers use to invoke this group
</div>
<div className="flex items-center gap-2 border border-gray-200 rounded-lg px-3 py-2 bg-white">
<CodeOutlined className="text-gray-400" />
<input
type="text"
value={logicalName}
onChange={(e) => setLogicalName(e.target.value)}
placeholder="prod-model"
className="flex-1 border-none outline-none text-sm font-mono text-gray-800 bg-transparent"
/>
{/* Weight distribution bar */}
{strategy === "weighted" && deployments.length > 0 && (
<div className="mb-3">
<div style={{ width: "100%", height: 6, borderRadius: 3, overflow: "hidden", display: "flex", backgroundColor: "#e5e7eb" }}>
{deployments.map((dep, i) => {
const pct = totalWeight > 0 ? ((dep.weight ?? 1) / totalWeight) * 100 : 0;
return <div key={dep.model_id + i} style={{ width: `${pct}%`, height: "100%", backgroundColor: DEPLOY_COLORS[i % DEPLOY_COLORS.length] }} />;
})}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
{deployments.map((dep, i) => {
const pct = totalWeight > 0 ? Math.round(((dep.weight ?? 1) / totalWeight) * 100) : 0;
const shortName = dep.model_name.split("/").pop() || dep.model_name;
return (
<div key={dep.model_id + i} className="flex items-center gap-1">
<div style={{ width: 6, height: 6, borderRadius: "50%", backgroundColor: DEPLOY_COLORS[i % DEPLOY_COLORS.length] }} />
<span className="text-xs text-gray-500">{shortName}: {pct}%</span>
</div>
);
})}
</div>
</div>
)}
<div className="flex flex-col gap-2">
{deployments.map((dep, idx) => {
const pct = totalWeight > 0 ? Math.round(((dep.weight ?? 1) / totalWeight) * 100) : 0;
return (
<div
key={dep.model_id + idx}
draggable
onDragStart={() => handleDragStart(idx)}
onDragEnter={() => handleDragEnter(idx)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
className="rounded-lg border border-gray-200 bg-white px-3 py-2.5 cursor-grab active:cursor-grabbing hover:border-gray-300 transition-colors"
>
<div className="flex items-center gap-2">
<HolderOutlined className="text-gray-300 text-xs" />
<span className="text-xs font-bold text-gray-400 w-4 text-center">{idx + 1}</span>
<div className="flex-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<ModelSelector
accessToken={effectiveToken}
value={dep.model_name}
placeholder="Select a model..."
showLabel={false}
onChange={(model) => handleDeploymentChange(idx, model)}
/>
</div>
<button
onClick={() => removeDeployment(idx)}
className="text-gray-300 hover:text-red-400 bg-transparent border-none cursor-pointer p-0 flex-shrink-0"
>
<DeleteOutlined style={{ fontSize: 13 }} />
</button>
</div>
{strategy === "weighted" && (
<div className="flex items-center gap-2 mt-2 ml-6" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-gray-500">Weight</span>
<InputNumber
size="small"
min={1}
max={100}
value={dep.weight ?? 1}
onChange={(val) => updateWeight(idx, val)}
addonAfter="%"
style={{ width: 90 }}
/>
<span className="text-xs text-gray-400">= {pct}% traffic</span>
</div>
)}
</div>
);
})}
{Array.from({ length: emptySlots }).map((_, slotIdx) => (
<div key={`slot-${slotIdx}`} className="rounded-lg border border-dashed border-gray-200 bg-gray-50 px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-gray-300 w-4 text-center">{deployments.length + slotIdx + 1}</span>
<div className="flex-1">
<ModelSelector
accessToken={effectiveToken}
placeholder="Select a model..."
showLabel={false}
onChange={(model) => handleSlotSelected(slotIdx, model)}
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* 3 · Logical Model Name */}
<div className="mb-5">
<div className="text-xs font-semibold text-gray-400 tracking-wide uppercase mb-1">3 &middot; Logical Model Name</div>
<div className="text-xs text-gray-400 mb-2">What callers use to invoke this group</div>
<div className="flex items-center gap-2 border border-gray-200 rounded-lg px-3 py-2 bg-white">
<CodeOutlined className="text-gray-400" />
<input
type="text"
value={logicalName}
onChange={(e) => setLogicalName(e.target.value)}
placeholder="prod-model"
className="flex-1 border-none outline-none text-sm font-mono text-gray-800 bg-transparent"
/>
</div>
<div className="mt-2 rounded-lg bg-gray-900 px-4 py-3 font-mono text-xs text-gray-300 leading-relaxed overflow-x-auto">
<pre className="m-0 whitespace-pre">{codeSnippet}</pre>
</div>
</div>
<Button
type="primary"
size="large"
block
loading={submitting}
disabled={!logicalName.trim() || deployments.length === 0}
onClick={handleSubmit}
style={{ height: 44, borderRadius: 8, fontWeight: 600, fontSize: 14 }}
>
{isEditing ? "Update Routing Group" : "Save Routing Group"}
</Button>
</div>
{/* Code snippet */}
<div className="mt-3 rounded-lg bg-gray-900 px-4 py-3 font-mono text-xs text-gray-300 leading-relaxed overflow-x-auto">
<pre className="m-0 whitespace-pre">{codeSnippet}</pre>
</div>
</div>
{/* ── RIGHT: Light Flow Preview ── */}
<div style={{ flex: 1, minWidth: 0, backgroundColor: "#f3f4f6", display: "flex", flexDirection: "column" }}>
{/* Header */}
<div
className="flex items-center justify-between"
style={{ padding: "10px 24px", borderBottom: "1px solid #e5e7eb", backgroundColor: "#fff", flexShrink: 0 }}
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>Flow Preview</span>
<div className="flex items-center gap-2">
<div className="flex" style={{ border: "1px solid #e5e7eb", borderRadius: 6, overflow: "hidden" }}>
{["1x", "2x", "5x"].map((label) => (
<button
key={label}
className="text-xs px-2.5 py-1 bg-white hover:bg-gray-50 border-none cursor-pointer text-gray-500"
style={{ borderRight: label !== "5x" ? "1px solid #e5e7eb" : "none" }}
>
{label}
</button>
))}
</div>
<button className="flex items-center gap-1 text-xs text-gray-500 px-2.5 py-1 border border-gray-200 rounded-md bg-white hover:bg-gray-50 cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 4v6h6M23 20v-6h-6" /><path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
Reset
</button>
<button
className="flex items-center gap-1 text-xs text-white px-3 py-1.5 border-none rounded-md cursor-pointer font-semibold"
style={{ backgroundColor: "#111827" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="#fff" stroke="none"><polygon points="6,3 20,12 6,21" /></svg>
Run
</button>
</div>
</div>
{/* Save button */}
<div className="mt-8 mb-4">
<Button
type="primary"
size="large"
block
loading={submitting}
disabled={!logicalName.trim() || deployments.length === 0}
onClick={handleSubmit}
style={{
height: 48,
borderRadius: 12,
fontWeight: 600,
fontSize: 15,
}}
>
{isEditing ? "Update Routing Group" : "Save Routing Group"}
</Button>
{/* Flow canvas */}
<div style={{ flex: 1, overflowY: "auto", padding: "24px", display: "flex", justifyContent: "center" }}>
<div style={{ maxWidth: 520, width: "100%" }}>
<RoutingFlowVisualization strategy={strategy} deployments={deployments} />
</div>
</div>
{/* Stats footer */}
<div style={{ borderTop: "1px solid #e5e7eb", backgroundColor: "#fff", padding: "0 24px", flexShrink: 0 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", borderBottom: "1px solid #e5e7eb" }}>
{[
{ label: "REQUESTS", value: "0" },
{ label: "SUCCESS", value: "100%", color: "#22c55e" },
{ label: "AVG LATENCY", value: "—" },
{ label: "FALLBACKS", value: "0" },
].map((stat, i) => (
<div key={stat.label} style={{ padding: "12px 0", borderLeft: i > 0 ? "1px solid #e5e7eb" : "none", paddingLeft: i > 0 ? 16 : 0 }}>
<div style={{ fontSize: 10, fontWeight: 600, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.05em" }}>{stat.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: stat.color ?? "#111827", marginTop: 2 }}>{stat.value}</div>
</div>
))}
</div>
<div className="flex items-center gap-2" style={{ padding: "10px 0" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /><polyline points="14 2 14 8 20 8" />
</svg>
<span style={{ fontSize: 13, fontWeight: 500, color: "#374151" }}>Request Log</span>
</div>
</div>
</div>
</div>
</div>
);

View file

@ -1,26 +1,28 @@
"use client";
import React, { useEffect, useState } from "react";
import { Button, Popconfirm, Table, Tag, Typography } from "antd";
import { DeleteOutlined, EditOutlined, ThunderboltOutlined } from "@ant-design/icons";
import { routingGroupDeleteCall, routingGroupListCall } from "@/components/networking";
import React from "react";
import { Badge } from "@tremor/react";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
import { Button, Popconfirm, Tooltip } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
interface RoutingGroupsTableProps {
accessToken: string | null;
refreshKey: number;
onEdit: (group: Record<string, unknown>) => void;
onTest?: (group: Record<string, unknown>) => void;
interface RoutingGroupRow {
routing_group_id: string;
routing_group_name: string;
routing_strategy: string;
deployments: unknown[];
is_active: boolean;
created_at: string;
description?: string;
[key: string]: unknown;
}
const strategyColors: Record<string, string> = {
"priority-failover": "blue",
weighted: "green",
"cost-based-routing": "gold",
"latency-based-routing": "cyan",
"least-busy": "purple",
"usage-based-routing-v2": "orange",
"simple-shuffle": "geekblue",
};
interface RoutingGroupsTableProps {
data: RoutingGroupRow[];
loading: boolean;
onEdit: (group: Record<string, unknown>) => void;
onDelete: (groupId: string) => void;
}
const strategyLabels: Record<string, string> = {
"priority-failover": "Priority Failover",
@ -32,137 +34,120 @@ const strategyLabels: Record<string, string> = {
"simple-shuffle": "Round Robin",
};
const strategyColors: Record<string, "blue" | "emerald" | "amber" | "cyan" | "violet" | "orange" | "indigo" | "gray"> = {
"priority-failover": "blue",
weighted: "emerald",
"cost-based-routing": "amber",
"latency-based-routing": "cyan",
"least-busy": "violet",
"usage-based-routing-v2": "orange",
"simple-shuffle": "indigo",
};
export default function RoutingGroupsTable({
accessToken,
refreshKey,
data,
loading,
onEdit,
onTest,
onDelete,
}: RoutingGroupsTableProps) {
const [groups, setGroups] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!accessToken) return;
setLoading(true);
routingGroupListCall(accessToken, 1, 50)
.then((resp) => {
setGroups(resp.routing_groups || []);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [accessToken, refreshKey]);
const handleDelete = async (groupId: string) => {
if (!accessToken) return;
try {
await routingGroupDeleteCall(accessToken, groupId);
setGroups((prev) =>
prev.filter((g) => (g.routing_group_id as string) !== groupId)
);
} catch (err) {
console.error("Failed to delete routing group:", err);
}
};
const columns = [
{
title: "Name",
dataIndex: "routing_group_name",
key: "routing_group_name",
render: (name: string) => (
<Typography.Text strong>{name}</Typography.Text>
),
},
{
title: "Strategy",
dataIndex: "routing_strategy",
key: "routing_strategy",
render: (strategy: string) => {
const label = strategyLabels[strategy] ?? strategy;
const color = strategyColors[strategy] ?? "default";
return <Tag color={color}>{label}</Tag>;
},
},
{
title: "Deployments",
dataIndex: "deployments",
key: "deployments",
render: (deployments: unknown) => {
const count = Array.isArray(deployments) ? deployments.length : 0;
return <Typography.Text>{count}</Typography.Text>;
},
},
{
title: "Status",
dataIndex: "status",
key: "status",
render: (status: string) => {
const isActive = !status || status === "active";
return (
<Tag color={isActive ? "success" : "default"}>
{isActive ? "Active" : "Inactive"}
</Tag>
);
},
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
render: (created: string) => {
if (!created) return "-";
return new Date(created).toLocaleDateString();
},
},
{
title: "Actions",
key: "actions",
render: (_: unknown, record: Record<string, unknown>) => {
const groupId = record.routing_group_id as string;
return (
<div style={{ display: "flex", gap: 8 }}>
{onTest && (
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => onTest(record)}
style={{ color: "#14b8a6", borderColor: "#14b8a6" }}
>
Test
</Button>
)}
<Button
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
>
Edit
</Button>
<Popconfirm
title="Delete routing group?"
description="This action cannot be undone."
onConfirm={() => handleDelete(groupId)}
okText="Delete"
okButtonProps={{ danger: true }}
cancelText="Cancel"
>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</div>
);
},
},
];
return (
<Table
dataSource={groups}
columns={columns}
loading={loading}
rowKey={(record) => (record.routing_group_id as string) ?? Math.random().toString()}
pagination={{ pageSize: 20 }}
/>
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-2 [&_th]:py-2" style={{ minWidth: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<TableHeaderCell className="py-1 h-8" style={{ width: 200 }}>Name</TableHeaderCell>
<TableHeaderCell className="py-1 h-8" style={{ width: 160 }}>Strategy</TableHeaderCell>
<TableHeaderCell className="py-1 h-8" style={{ width: 120 }}>Deployments</TableHeaderCell>
<TableHeaderCell className="py-1 h-8" style={{ width: 100 }}>Status</TableHeaderCell>
<TableHeaderCell className="py-1 h-8" style={{ width: 140 }}>Created At</TableHeaderCell>
<TableHeaderCell className="py-1 h-8" style={{ width: 100 }}></TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-8 text-center">
<div className="text-center text-gray-500 py-4">Loading routing groups...</div>
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-8 text-center">
<div className="text-center text-gray-500 py-4">No routing groups yet. Create one to get started.</div>
</TableCell>
</TableRow>
) : (
data.map((row) => {
const strat = row.routing_strategy;
const stratLabel = strategyLabels[strat] ?? strat;
const stratColor = strategyColors[strat] ?? "gray";
const deps = row.deployments;
const depCount = Array.isArray(deps) ? deps.length : 0;
const active = row.is_active !== false;
const created = row.created_at;
return (
<TableRow
key={row.routing_group_id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => onEdit(row)}
>
<TableCell className="py-0.5" style={{ width: 200 }}>
<span className="text-sm font-semibold text-blue-600 hover:underline">
{row.routing_group_name}
</span>
</TableCell>
<TableCell className="py-0.5" style={{ width: 160 }}>
<Badge size="xs" color={stratColor}>{stratLabel}</Badge>
</TableCell>
<TableCell className="py-0.5" style={{ width: 120 }}>
<span className="text-sm text-gray-600">{depCount} model{depCount !== 1 ? "s" : ""}</span>
</TableCell>
<TableCell className="py-0.5" style={{ width: 100 }}>
<Badge size="xs" color={active ? "emerald" : "gray"}>
{active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="py-0.5" style={{ width: 140 }}>
{created ? (
<span className="text-sm text-gray-500">{new Date(created).toLocaleDateString()}</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</TableCell>
<TableCell className="py-0.5" style={{ width: 100 }}>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Tooltip title="Edit">
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => onEdit(row)}
className="text-gray-500 hover:text-gray-700"
/>
</Tooltip>
<Popconfirm
title="Delete routing group?"
description="This action cannot be undone."
onConfirm={() => onDelete(row.routing_group_id)}
okText="Delete"
okButtonProps={{ danger: true }}
cancelText="Cancel"
>
<Tooltip title="Delete">
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -1,11 +1,22 @@
"use client";
import React, { useState } from "react";
import { Button, Divider, Typography } from "antd";
import { PlusOutlined, CloseOutlined } from "@ant-design/icons";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, Skeleton, Select } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import RoutingGroupsTable from "./RoutingGroupsTable";
import RoutingGroupBuilder from "./RoutingGroupBuilder";
import LiveTester from "./LiveTester";
import { routingGroupListCall, routingGroupDeleteCall } from "@/components/networking";
interface RoutingGroupRow {
routing_group_id: string;
routing_group_name: string;
routing_strategy: string;
deployments: unknown[];
is_active: boolean;
created_at: string;
description?: string;
[key: string]: unknown;
}
interface RoutingGroupsViewProps {
accessToken: string | null;
@ -13,6 +24,17 @@ interface RoutingGroupsViewProps {
userId: string;
}
const STRATEGY_OPTIONS = [
{ value: "all", label: "All Strategies" },
{ value: "priority-failover", label: "Priority Failover" },
{ value: "weighted", label: "Weighted" },
{ value: "cost-based-routing", label: "Cost-Based" },
{ value: "latency-based-routing", label: "Latency-Based" },
{ value: "least-busy", label: "Least Busy" },
{ value: "usage-based-routing-v2", label: "Usage-Based" },
{ value: "simple-shuffle", label: "Round Robin" },
];
export default function RoutingGroupsView({
accessToken,
userRole,
@ -21,16 +43,82 @@ export default function RoutingGroupsView({
const [showBuilder, setShowBuilder] = useState(false);
const [editTarget, setEditTarget] = useState<Record<string, unknown> | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [selectedGroup, setSelectedGroup] = useState<Record<string, unknown> | null>(null);
const [groups, setGroups] = useState<RoutingGroupRow[]>([]);
const [loading, setLoading] = useState(false);
const [nameSearch, setNameSearch] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [selectedStrategy, setSelectedStrategy] = useState<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 50;
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [debouncedSearch, setDebouncedSearch] = useState("");
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
setDebouncedSearch(nameSearch);
}, 200);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, [nameSearch]);
useEffect(() => {
if (!accessToken) return;
setLoading(true);
routingGroupListCall(accessToken, 1, 500)
.then((resp) => {
setGroups((resp.routing_groups as RoutingGroupRow[]) || []);
})
.catch(() => {
setGroups([]);
})
.finally(() => setLoading(false));
}, [accessToken, refreshKey]);
const filteredData = useMemo(() => {
return groups.filter((g) => {
const nameMatch =
!debouncedSearch ||
g.routing_group_name.toLowerCase().includes(debouncedSearch.toLowerCase());
const strategyMatch =
selectedStrategy === "all" || g.routing_strategy === selectedStrategy;
return nameMatch && strategyMatch;
});
}, [groups, debouncedSearch, selectedStrategy]);
const totalCount = filteredData.length;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const paginatedData = filteredData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
useEffect(() => {
setCurrentPage(1);
}, [selectedStrategy, debouncedSearch]);
const resetFilters = () => {
setNameSearch("");
setDebouncedSearch("");
setSelectedStrategy("all");
setCurrentPage(1);
};
const handleDelete = async (groupId: string) => {
if (!accessToken) return;
try {
await routingGroupDeleteCall(accessToken, groupId);
setGroups((prev) => prev.filter((g) => g.routing_group_id !== groupId));
} catch (err) {
console.error("Failed to delete routing group:", err);
}
};
const handleEdit = (group: Record<string, unknown>) => {
setEditTarget(group);
setShowBuilder(true);
setSelectedGroup(null);
};
const handleTest = (group: Record<string, unknown>) => {
setSelectedGroup(group);
};
const handleClose = () => {
@ -58,16 +146,9 @@ export default function RoutingGroupsView({
}
return (
<div className="w-full mx-4">
<div className="flex justify-between items-center mb-6">
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
Routing Groups
</Typography.Title>
<Typography.Text type="secondary">
Configure named routing pipelines with fallback strategies
</Typography.Text>
</div>
<div className="w-full max-w-screen p-6 overflow-x-hidden box-border">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Routing Groups</h1>
<Button
type="primary"
icon={<PlusOutlined />}
@ -77,39 +158,137 @@ export default function RoutingGroupsView({
</Button>
</div>
<RoutingGroupsTable
accessToken={accessToken}
refreshKey={refreshKey}
onEdit={handleEdit}
onTest={handleTest}
/>
<div className="bg-white rounded-lg shadow w-full max-w-full box-border">
{/* Search and Filter Controls */}
<div className="border-b px-6 py-4">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-64">
<input
type="text"
placeholder="Search routing groups..."
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={nameSearch}
onChange={(e) => setNameSearch(e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{selectedGroup && accessToken && (
<>
<Divider />
<div className="flex items-center justify-between mb-4">
<Typography.Text type="secondary" className="text-sm">
Testing:{" "}
<span className="font-mono font-semibold">
{selectedGroup.routing_group_name as string}
</span>
</Typography.Text>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => setSelectedGroup(null)}
>
Close
</Button>
<button
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
onClick={() => setShowFilters(!showFilters)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filters
</button>
<button
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
onClick={resetFilters}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Reset Filters
</button>
</div>
</div>
{showFilters && (
<div className="flex flex-wrap items-center gap-3 mt-3">
<div className="w-64">
<Select
className="w-full"
value={selectedStrategy}
onChange={(value) => setSelectedStrategy(value)}
placeholder="Filter by Strategy"
showSearch
options={STRATEGY_OPTIONS}
/>
</div>
</div>
)}
<div className="flex justify-between items-center">
{loading ? (
<Skeleton.Input active style={{ width: 184, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
{totalCount > 0
? `Showing ${(currentPage - 1) * pageSize + 1} - ${Math.min(currentPage * pageSize, totalCount)} of ${totalCount} results`
: "Showing 0 results"}
</span>
)}
<div className="flex items-center space-x-2">
{loading ? (
<Skeleton.Button active style={{ width: 84, height: 30 }} />
) : (
<button
onClick={() => setCurrentPage((p) => p - 1)}
disabled={currentPage === 1}
className={`px-3 py-1 text-sm border rounded-md ${
currentPage === 1
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
>
Previous
</button>
)}
{loading ? (
<Skeleton.Button active style={{ width: 56, height: 30 }} />
) : (
<button
onClick={() => setCurrentPage((p) => p + 1)}
disabled={currentPage >= totalPages}
className={`px-3 py-1 text-sm border rounded-md ${
currentPage >= totalPages
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
>
Next
</button>
)}
</div>
</div>
</div>
<LiveTester
accessToken={accessToken}
routingGroupId={selectedGroup.routing_group_id as string}
routingGroupName={selectedGroup.routing_group_name as string}
routingStrategy={(selectedGroup.routing_strategy as string) ?? "weighted"}
/>
</>
)}
</div>
<RoutingGroupsTable
data={paginatedData}
loading={loading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
</div>
);
}