mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
SAVE
This commit is contained in:
parent
b5b0b77e0e
commit
f39adbc50b
5 changed files with 1049 additions and 666 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 · 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 · 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 · 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">→</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 · 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 · 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 · 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue