refactor(ui): migrate routing groups table onto the shared DataTable

Rebuilds the Router Settings > Routing Groups table on the shared DataTable
and cell library, the last antd entity grid in the dashboard.

The table splits into a thin RoutingGroupsTable container plus
RoutingGroupsTableColumns, with the usage snippets moving to their own
RoutingGroupUsagePanel on ui/tabs and the shared CodeBlock instead of antd
Tabs and Paragraph copyable. Models render through the shared ModelsCell so
long lists collapse behind "+N more" rather than wrapping the row, and the
two inline icon buttons become a single overflow menu with Edit and Delete.

antd gave the snippet panel its own chevron column; under the shared pattern
a row has two click targets, the name cell and the overflow menu, so clicking
the group name now opens the panel. Column set, order, actions, and the
backend row order are otherwise unchanged.
This commit is contained in:
Yuneng Jiang 2026-07-24 16:08:29 -07:00
parent 57ed2ed718
commit 9e56630347
No known key found for this signature in database
7 changed files with 411 additions and 213 deletions

View file

@ -2964,11 +2964,6 @@
"count": 1
}
},
"src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/budget_duration_dropdown.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3554,11 +3549,6 @@
"count": 1
}
},
"src/components/routing_groups/RoutingGroupsTable.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/routing_groups/index.tsx": {
"local/filename-pascal-case": {
"count": 1

View file

@ -0,0 +1,91 @@
"use client";
import { Code2 } from "lucide-react";
import React from "react";
import CodeBlock from "@/components/CodeBlock";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatStrategyLabel } from "./strategy";
import type { RoutingGroup } from "./types";
interface RoutingGroupUsagePanelProps {
group: RoutingGroup;
baseUrl: string;
}
const exampleModel = (group: RoutingGroup): string => group.models[0] ?? "<your-model>";
const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string =>
`curl -X POST '${baseUrl}/v1/chat/completions' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer $LITELLM_API_KEY' \\
-d '{
"model": "${exampleModel(group)}",
"messages": [{"role": "user", "content": "Hello!"}]
}'`;
const buildPythonSnippet = (group: RoutingGroup, baseUrl: string): string =>
`from openai import OpenAI
client = OpenAI(
api_key="$LITELLM_API_KEY",
base_url="${baseUrl}",
)
response = client.chat.completions.create(
model="${exampleModel(group)}",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response)`;
const buildJsSnippet = (group: RoutingGroup, baseUrl: string): string =>
`import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.LITELLM_API_KEY,
baseURL: "${baseUrl}",
});
const response = await client.chat.completions.create({
model: "${exampleModel(group)}",
messages: [{ role: "user", content: "Hello!" }],
});
console.log(response);`;
const SNIPPET_TABS = [
{ value: "curl", label: "cURL", language: "bash", build: buildCurlSnippet },
{ value: "python", label: "Python (OpenAI SDK)", language: "python", build: buildPythonSnippet },
{ value: "javascript", label: "JavaScript (OpenAI SDK)", language: "javascript", build: buildJsSnippet },
] as const;
export function RoutingGroupUsagePanel({ group, baseUrl }: RoutingGroupUsagePanelProps) {
return (
<div className="border-y bg-muted/40 px-4 py-4">
<div className="mb-2 flex items-center gap-2">
<Code2 className="size-4 text-primary" />
<span className="text-sm font-medium text-foreground">How routing works for this group</span>
</div>
<p className="mb-3 text-sm text-muted-foreground">
Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "}
<span className="font-medium text-foreground">{formatStrategyLabel(group.routing_strategy)}</span> strategy.
</p>
<Tabs defaultValue="curl">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
{SNIPPET_TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="flex-none rounded-none px-4 py-2">
{tab.label}
</TabsTrigger>
))}
</TabsList>
{SNIPPET_TABS.map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="pt-3">
<CodeBlock language={tab.language} code={tab.build(group, baseUrl)} />
</TabsContent>
))}
</Tabs>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import RoutingGroupsTable from "./RoutingGroupsTable";
import type { RoutingGroup } from "./types";
describe("RoutingGroupsTable", () => {
const onEdit = vi.fn();
const onDelete = vi.fn();
const prodGroup: RoutingGroup = {
group_name: "prod-group",
models: ["gpt-4o", "claude-sonnet-4-5"],
routing_strategy: "usage-based-routing",
};
const devGroup: RoutingGroup = {
group_name: "dev-group",
models: ["gpt-4o-mini"],
routing_strategy: "simple-shuffle",
};
const defaultProps = {
groups: [] as RoutingGroup[],
onEdit,
onDelete,
proxyBaseUrl: "https://proxy.example.com",
};
const rowFor = (groupName: string): HTMLElement => {
const row = document.querySelector(`[data-row-id="${groupName}"]`);
if (!(row instanceof HTMLElement)) {
throw new Error(`No row rendered for ${groupName}`);
}
return row;
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render every column header", () => {
render(<RoutingGroupsTable {...defaultProps} />);
for (const header of ["Group Name", "Models", "Strategy"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
});
it("should show the empty state when there are no groups", () => {
render(<RoutingGroupsTable {...defaultProps} />);
expect(screen.getByText("No routing groups yet")).toBeInTheDocument();
});
it("should render the group name, its models, and a human-readable strategy label", () => {
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
const row = rowFor("prod-group");
expect(within(row).getByText("prod-group")).toBeInTheDocument();
expect(within(row).getByText("gpt-4o")).toBeInTheDocument();
expect(within(row).getByText("claude-sonnet-4-5")).toBeInTheDocument();
expect(within(row).getByText("Usage Based")).toBeInTheDocument();
});
it("should fall back to the raw strategy value when it has no friendly label", () => {
render(<RoutingGroupsTable {...defaultProps} groups={[{ ...prodGroup, routing_strategy: "custom-strategy" }]} />);
expect(within(rowFor("prod-group")).getByText("custom-strategy")).toBeInTheDocument();
});
it("should collapse models beyond the first three behind a +N more badge", () => {
const wideGroup: RoutingGroup = { ...prodGroup, models: ["a", "b", "c", "d", "e"] };
render(<RoutingGroupsTable {...defaultProps} groups={[wideGroup]} />);
const row = rowFor("prod-group");
expect(within(row).getByText("+2 more")).toBeInTheDocument();
expect(within(row).queryByText("d")).not.toBeInTheDocument();
});
it("should keep the incoming order until a column is sorted", async () => {
const user = userEvent.setup();
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup, devGroup]} />);
const namesInOrder = () =>
screen
.getAllByRole("row")
.slice(1)
.map((row) => row.getAttribute("data-row-id"));
expect(namesInOrder()).toEqual(["prod-group", "dev-group"]);
await user.click(screen.getByTestId("sort-header-group_name"));
expect(namesInOrder()).toEqual(["dev-group", "prod-group"]);
});
it("should toggle the usage panel when the group name is clicked", async () => {
const user = userEvent.setup();
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
expect(screen.queryByText("How routing works for this group")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "prod-group" }));
expect(await screen.findByText("How routing works for this group")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "prod-group" }));
expect(screen.queryByText("How routing works for this group")).not.toBeInTheDocument();
});
it("should build the usage snippet from the proxy base url and the group's first model", async () => {
const user = userEvent.setup();
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
await user.click(screen.getByRole("button", { name: "prod-group" }));
const panel = (await screen.findByText("How routing works for this group")).closest("div")?.parentElement;
expect(panel?.textContent).toContain("https://proxy.example.com");
expect(panel?.textContent).toContain("gpt-4o");
});
it("should expand only the clicked group", async () => {
const user = userEvent.setup();
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup, devGroup]} />);
await user.click(screen.getByRole("button", { name: "dev-group" }));
expect(await screen.findAllByText("How routing works for this group")).toHaveLength(1);
expect(within(rowFor("prod-group")).queryByText("How routing works for this group")).not.toBeInTheDocument();
});
it("should edit a group through the actions menu", async () => {
const user = userEvent.setup();
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
await user.click(screen.getByTestId("routing-group-actions-prod-group"));
await user.click(await screen.findByTestId("routing-group-action-edit"));
expect(onEdit).toHaveBeenCalledWith(prodGroup);
});
it("should delete a group through the actions menu", async () => {
const user = userEvent.setup();
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
await user.click(screen.getByTestId("routing-group-actions-prod-group"));
await user.click(await screen.findByTestId("routing-group-action-delete"));
expect(onDelete).toHaveBeenCalledWith(prodGroup);
});
it("should show skeleton rows instead of the empty state while loading", () => {
render(<RoutingGroupsTable {...defaultProps} isLoading />);
expect(screen.queryByText("No routing groups yet")).not.toBeInTheDocument();
});
});

View file

@ -1,229 +1,82 @@
"use client";
import React, { useState } from "react";
import { Flex, Table, Tabs, Tag, Tooltip, Typography, Button } from "antd";
import type { ColumnsType } from "antd/es/table";
import { BranchesOutlined, DeleteOutlined, EditOutlined, CodeOutlined } from "@ant-design/icons";
import type { RoutingGroup } from "./types";
import type { ExpandedState, SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
const { Text, Paragraph } = Typography;
import { DataTable } from "@/components/shared/DataTable";
import { RoutingGroupUsagePanel } from "./RoutingGroupUsagePanel";
import { getRoutingGroupsTableColumns } from "./RoutingGroupsTableColumns";
import type { RoutingGroup } from "./types";
interface RoutingGroupsTableProps {
groups: RoutingGroup[];
loading?: boolean;
isLoading?: boolean;
onEdit: (group: RoutingGroup) => void;
onDelete: (group: RoutingGroup) => void;
proxyBaseUrl?: string;
}
const formatStrategyLabel = (strategy: string): string => {
switch (strategy) {
case "simple-shuffle":
return "Simple Shuffle";
case "least-busy":
return "Least Busy";
case "usage-based-routing":
return "Usage Based";
case "latency-based-routing":
return "Latency Based";
default:
return strategy;
}
};
const resolveBaseUrl = (proxyBaseUrl?: string): string => {
if (proxyBaseUrl && proxyBaseUrl.trim()) return proxyBaseUrl;
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
return "<your_proxy_base_url>";
};
const exampleModel = (group: RoutingGroup): string => group.models[0] ?? "<your-model>";
const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string =>
`curl -X POST '${baseUrl}/v1/chat/completions' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer $LITELLM_API_KEY' \\
-d '{
"model": "${exampleModel(group)}",
"messages": [{"role": "user", "content": "Hello!"}]
}'`;
const buildPythonSnippet = (group: RoutingGroup, baseUrl: string): string =>
`from openai import OpenAI
client = OpenAI(
api_key="$LITELLM_API_KEY",
base_url="${baseUrl}",
)
response = client.chat.completions.create(
model="${exampleModel(group)}",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response)`;
const buildJsSnippet = (group: RoutingGroup, baseUrl: string): string =>
`import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.LITELLM_API_KEY,
baseURL: "${baseUrl}",
});
const response = await client.chat.completions.create({
model: "${exampleModel(group)}",
messages: [{ role: "user", content: "Hello!" }],
});
console.log(response);`;
interface RoutingGroupSnippetProps {
group: RoutingGroup;
baseUrl: string;
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No routing groups yet</div>
<div className="text-sm text-muted-foreground">
Create a group to load-balance a set of models behind one name.
</div>
</div>
);
}
const SNIPPET_BLOCK_STYLE: React.CSSProperties = {
backgroundColor: "#111827",
color: "#f3f4f6",
borderRadius: 6,
padding: 16,
fontSize: 12,
whiteSpace: "pre",
overflowX: "auto",
};
const RoutingGroupSnippet: React.FC<RoutingGroupSnippetProps> = ({ group, baseUrl }) => {
const snippets = {
curl: buildCurlSnippet(group, baseUrl),
python: buildPythonSnippet(group, baseUrl),
javascript: buildJsSnippet(group, baseUrl),
} as const;
type SnippetKey = keyof typeof snippets;
const [activeKey, setActiveKey] = useState<SnippetKey>("curl");
const items = [
{ key: "curl", label: "cURL" },
{ key: "python", label: "Python (OpenAI SDK)" },
{ key: "javascript", label: "JavaScript (OpenAI SDK)" },
].map(({ key, label }) => ({
key,
label,
children: (
<Paragraph code className="mb-0!" style={SNIPPET_BLOCK_STYLE}>
{snippets[key as SnippetKey]}
</Paragraph>
),
}));
return (
<Tabs
size="small"
activeKey={activeKey}
onChange={(k) => setActiveKey(k as SnippetKey)}
items={items}
tabBarExtraContent={
<Paragraph copyable={{ text: snippets[activeKey], tooltips: ["Copy", "Copied"] }} className="mb-0!" />
}
/>
);
};
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading, onEdit, onDelete, proxyBaseUrl }) => {
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
groups,
isLoading,
onEdit,
onDelete,
proxyBaseUrl,
}) => {
const [sorting, setSorting] = useState<SortingState>([]);
const [expanded, setExpanded] = useState<ExpandedState>({});
const baseUrl = resolveBaseUrl(proxyBaseUrl);
const columns: ColumnsType<RoutingGroup> = [
{
title: "GROUP NAME",
dataIndex: "group_name",
key: "group_name",
render: (name: string) => (
<Text strong className="text-blue-600">
{name}
</Text>
),
},
{
title: "MODELS",
dataIndex: "models",
key: "models",
render: (models: string[]) => (
<Flex wrap="wrap" gap={4}>
{models.map((m) => (
<Tag key={m}>{m}</Tag>
))}
</Flex>
),
},
{
title: "STRATEGY",
dataIndex: "routing_strategy",
key: "routing_strategy",
render: (strategy: string) => (
<span className="inline-flex items-center gap-1.5">
<BranchesOutlined className="text-gray-400" />
<Text>{formatStrategyLabel(strategy)}</Text>
</span>
),
},
{
title: "ACTIONS",
key: "actions",
width: 120,
align: "right",
render: (_, group) => (
<Flex justify="flex-end" align="center" gap={8}>
<Tooltip title="Edit">
<Button
type="text"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEdit(group);
}}
/>
</Tooltip>
<Tooltip title="Delete">
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
onDelete(group);
}}
/>
</Tooltip>
</Flex>
),
},
];
const toggleUsage = useCallback((group: RoutingGroup) => {
setExpanded((previous) => {
const current = previous === true ? {} : previous;
return { ...current, [group.group_name]: current[group.group_name] !== true };
});
}, []);
const columns = useMemo(() => {
const deps = { onEdit, onDelete, onToggleUsage: toggleUsage };
return getRoutingGroupsTableColumns(deps);
}, [onEdit, onDelete, toggleUsage]);
return (
<Table<RoutingGroup>
rowKey="group_name"
<DataTable
data={groups}
columns={columns}
dataSource={groups}
loading={loading}
pagination={false}
expandable={{
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
expandedRowRender: (group) => (
<div className="bg-gray-50 border border-gray-200 rounded-md p-4 my-2">
<Flex align="center" gap={8} className="mb-2">
<CodeOutlined className="text-blue-500" />
<Text strong>How routing works for this group</Text>
</Flex>
<Paragraph className="text-sm text-gray-600 mb-3">
Callers request any model in the group by name — LiteLLM picks a deployment behind the scenes using the{" "}
<Text strong>{formatStrategyLabel(group.routing_strategy)}</Text> strategy.
</Paragraph>
<RoutingGroupSnippet group={group} baseUrl={baseUrl} />
</div>
),
}}
getRowId={(group) => group.group_name}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
expanded={expanded}
onExpandedChange={setExpanded}
getRowCanExpand={() => true}
renderSubComponent={({ row }) => <RoutingGroupUsagePanel group={row.original} baseUrl={baseUrl} />}
isLoading={isLoading}
loadingMessage="Loading routing groups…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};

View file

@ -0,0 +1,111 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { GitBranch, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { IdentityCell, ModelsCell } from "@/components/shared/table_cells";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { formatStrategyLabel } from "./strategy";
import type { RoutingGroup } from "./types";
interface RoutingGroupRowActionsProps {
group: RoutingGroup;
onEdit: (group: RoutingGroup) => void;
onDelete: (group: RoutingGroup) => void;
}
function RoutingGroupRowActions({ group, onEdit, onDelete }: RoutingGroupRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Open actions for ${group.group_name}`}
data-testid={`routing-group-actions-${group.group_name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem data-testid="routing-group-action-edit" onClick={() => onEdit(group)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-testid="routing-group-action-delete"
onClick={() => onDelete(group)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface RoutingGroupsTableColumnsDeps {
onEdit: (group: RoutingGroup) => void;
onDelete: (group: RoutingGroup) => void;
onToggleUsage: (group: RoutingGroup) => void;
}
export const getRoutingGroupsTableColumns = ({
onEdit,
onDelete,
onToggleUsage,
}: RoutingGroupsTableColumnsDeps): ColumnDef<RoutingGroup>[] => [
{
id: "group_name",
accessorKey: "group_name",
meta: { title: "Group Name", skeleton: "text" },
header: ({ column }) => <DataTableSortHeader column={column} title="Group Name" />,
size: 240,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell title={row.original.group_name} className="max-w-60" onClick={() => onToggleUsage(row.original)} />
),
},
{
id: "models",
meta: { title: "Models", skeleton: "chips" },
header: "Models",
size: 320,
enableSorting: false,
cell: ({ row }) => <ModelsCell models={row.original.models} />,
},
{
id: "routing_strategy",
accessorKey: "routing_strategy",
meta: { title: "Strategy", skeleton: "text" },
header: ({ column }) => <DataTableSortHeader column={column} title="Strategy" />,
size: 180,
enableSorting: true,
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
{formatStrategyLabel(row.original.routing_strategy)}
</span>
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<RoutingGroupRowActions group={row.original} onEdit={onEdit} onDelete={onDelete} />
</div>
),
},
];

View file

@ -126,7 +126,7 @@ const RoutingGroups: React.FC = () => {
<RoutingGroupsTable
groups={filteredGroups}
loading={isLoading}
isLoading={isLoading}
onEdit={openEdit}
onDelete={(g) => setDeletingGroup(g)}
proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""}

View file

@ -0,0 +1,8 @@
const STRATEGY_LABELS: Readonly<Record<string, string>> = {
"simple-shuffle": "Simple Shuffle",
"least-busy": "Least Busy",
"usage-based-routing": "Usage Based",
"latency-based-routing": "Latency Based",
};
export const formatStrategyLabel = (strategy: string): string => STRATEGY_LABELS[strategy] ?? strategy;