From 7ea407b239dabd90280014c6fdebfc16ea7b2c63 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 11 Dec 2025 20:23:02 -0800 Subject: [PATCH 1/2] Rearrange Links UI --- .../TableIconActionButton.tsx | 13 +- .../search_tools/search_tool_columns.tsx | 27 +--- .../useful_links_management.test.tsx | 35 +++++ .../components/useful_links_management.tsx | 131 +++++++++++++++--- 4 files changed, 161 insertions(+), 45 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx index 7259f763ca5..488913a734a 100644 --- a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx @@ -1,4 +1,12 @@ -import { PencilAltIcon, PlayIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline"; +import { + PencilAltIcon, + PlayIcon, + RefreshIcon, + TrashIcon, + ChevronUpIcon, + ChevronDownIcon, + ExternalLinkIcon, +} from "@heroicons/react/outline"; import { Tooltip } from "antd"; import BaseActionButton from "../BaseActionButton"; @@ -21,6 +29,9 @@ export const TableIconActionButtonMap: Record ( - {getValue() as string} - ), + cell: ({ getValue }) => {getValue() as string}, }, { id: "provider", header: "Provider", cell: ({ row }) => { const provider = row.original.litellm_params.search_provider; - const providerInfo = availableProviders.find(p => p.provider_name === provider); + const providerInfo = availableProviders.find((p) => p.provider_name === provider); const displayName = providerInfo?.ui_friendly_name || provider; - - return ( - - {displayName} - - ); + + return {displayName}; }, }, { @@ -49,11 +43,7 @@ export const searchToolColumns = ( sortingFn: "datetime", cell: ({ row }) => { const tool = row.original; - return ( - - {tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"} - - ); + return {tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}; }, }, { @@ -62,11 +52,7 @@ export const searchToolColumns = ( sortingFn: "datetime", cell: ({ row }) => { const tool = row.original; - return ( - - {tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"} - - ); + return {tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}; }, }, { @@ -90,4 +76,3 @@ export const searchToolColumns = ( ), }, ]; - diff --git a/ui/litellm-dashboard/src/components/useful_links_management.test.tsx b/ui/litellm-dashboard/src/components/useful_links_management.test.tsx index b70d5363599..7b1a40a499a 100644 --- a/ui/litellm-dashboard/src/components/useful_links_management.test.tsx +++ b/ui/litellm-dashboard/src/components/useful_links_management.test.tsx @@ -71,4 +71,39 @@ describe("UsefulLinksManagement", () => { expect(screen.getByText("https://docs.example.com")).toBeInTheDocument(); expect(mockedNotifications.success).toHaveBeenCalledWith("Link added successfully"); }); + + it("should rearrange links and save the new order", async () => { + const user = userEvent.setup(); + mockedGetPublicModelHubInfo.mockResolvedValue({ + docs_title: "Docs", + custom_docs_description: null, + litellm_version: "1.0.0", + useful_links: { + "First Link": "https://first.example.com", + "Second Link": "https://second.example.com", + "Third Link": "https://third.example.com", + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText("First Link")).toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: /rearrange order/i })); + + const secondLinkMoveUpButton = screen.getByTestId("move-up-1-Second Link"); + await user.click(secondLinkMoveUpButton); + + await user.click(screen.getByRole("button", { name: /save order/i })); + + await waitFor(() => + expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", { + "Second Link": "https://second.example.com", + "First Link": "https://first.example.com", + "Third Link": "https://third.example.com", + }), + ); + + expect(mockedNotifications.success).toHaveBeenCalledWith("Link order saved successfully"); + }); }); diff --git a/ui/litellm-dashboard/src/components/useful_links_management.tsx b/ui/litellm-dashboard/src/components/useful_links_management.tsx index 8ea1655b1e4..a9367812f4b 100644 --- a/ui/litellm-dashboard/src/components/useful_links_management.tsx +++ b/ui/litellm-dashboard/src/components/useful_links_management.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from "react"; import { Modal } from "antd"; -import { PlusCircleIcon, PencilIcon, TrashIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { PlusCircleIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; import { isAdminRole } from "../utils/roles"; import { getPublicModelHubInfo, updateUsefulLinksCall, getProxyBaseUrl } from "./networking"; import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; import NotificationsManager from "./molecules/notifications_manager"; +import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; interface UsefulLinksManagementProps { accessToken: string | null; @@ -23,6 +24,8 @@ const UsefulLinksManagement: React.FC = ({ accessTok const [editingLink, setEditingLink] = useState(null); const [loading, setLoading] = useState(false); const [isExpanded, setIsExpanded] = useState(true); + const [isRearranging, setIsRearranging] = useState(false); + const [originalLinksOrder, setOriginalLinksOrder] = useState([]); const fetchUsefulLinks = async () => { if (!accessToken) return; @@ -187,6 +190,42 @@ const UsefulLinksManagement: React.FC = ({ accessTok window.open(url, "_blank"); }; + const handleStartRearranging = () => { + if (editingLink) { + setEditingLink(null); + } + setOriginalLinksOrder([...links]); + setIsRearranging(true); + }; + + const handleCancelRearranging = () => { + setLinks([...originalLinksOrder]); + setIsRearranging(false); + setOriginalLinksOrder([]); + }; + + const handleSaveRearranging = async () => { + if (await saveLinksToBackend(links)) { + setIsRearranging(false); + setOriginalLinksOrder([]); + NotificationsManager.success("Link order saved successfully"); + } + }; + + const handleMoveUp = (index: number) => { + if (index === 0) return; + const newLinks = [...links]; + [newLinks[index - 1], newLinks[index]] = [newLinks[index], newLinks[index - 1]]; + setLinks(newLinks); + }; + + const handleMoveDown = (index: number) => { + if (index === links.length - 1) return; + const newLinks = [...links]; + [newLinks[index], newLinks[index + 1]] = [newLinks[index + 1], newLinks[index]]; + setLinks(newLinks); + }; + return (
setIsExpanded(!isExpanded)}> @@ -252,7 +291,32 @@ const UsefulLinksManagement: React.FC = ({ accessTok
- Manage Existing Links +
+ Manage Existing Links + {!isRearranging ? ( + + ) : ( +
+ + +
+ )} +
@@ -264,7 +328,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok - {links.map((link) => ( + {links.map((link, index) => ( {editingLink && editingLink.id === link.id ? ( <> @@ -316,26 +380,47 @@ const UsefulLinksManagement: React.FC = ({ accessTok {link.displayName} {link.url} -
- - - -
+ {isRearranging ? ( +
+ handleMoveUp(index)} + tooltipText="Move up" + disabled={index === 0} + disabledTooltipText="Already at the top" + dataTestId={`move-up-${link.id}`} + /> + handleMoveDown(index)} + tooltipText="Move down" + disabled={index === links.length - 1} + disabledTooltipText="Already at the bottom" + dataTestId={`move-down-${link.id}`} + /> +
+ ) : ( +
+ setCurrentLink(link.url)} + tooltipText="Open link" + dataTestId={`open-link-${link.id}`} + /> + handleEditLink(link)} + tooltipText="Edit link" + dataTestId={`edit-link-${link.id}`} + /> + deleteLink(link.id)} + tooltipText="Delete link" + dataTestId={`delete-link-${link.id}`} + /> +
+ )}
)} From d1e53365a859d3a4210adf0c2bd9ef377f1587fc Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 11 Dec 2025 21:25:40 -0800 Subject: [PATCH 2/2] Change useful_links to include index for UI --- litellm/__init__.py | 5 ++- .../model_management_endpoints.py | 7 ++- .../public_endpoints/public_endpoints.py | 7 ++- .../src/components/networking.tsx | 8 +++- .../src/components/public_model_hub.tsx | 30 ++++++++----- .../useful_links_management.test.tsx | 10 +++-- .../components/useful_links_management.tsx | 44 +++++++++++++++---- 7 files changed, 80 insertions(+), 31 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 2c6f04a3aef..ef44aa53a13 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -399,7 +399,10 @@ disable_copilot_system_to_assistant: bool = ( public_mcp_servers: Optional[List[str]] = None public_model_groups: Optional[List[str]] = None public_agent_groups: Optional[List[str]] = None -public_model_groups_links: Dict[str, str] = {} +# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]]) +# New format: { "displayName": { "url": "...", "index": 0 } } +# Old format: { "displayName": "url" } (for backward compatibility) +public_model_groups_links: Dict[str, Union[str, Dict[str, Any]]] = {} #### REQUEST PRIORITIZATION ####### priority_reservation: Optional[Dict[str, Union[float, PriorityReservationDict]]] = None priority_reservation_settings: "PriorityReservationSettings" = ( diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py index cb9dcc63e21..a8ff3971305 100644 --- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py @@ -1,4 +1,4 @@ -from typing import Dict, List +from typing import Dict, List, Union, Any from pydantic import BaseModel, Field @@ -10,7 +10,10 @@ class ModelGroupInfoProxy(ModelGroupInfo): class UpdateUsefulLinksRequest(BaseModel): - useful_links: Dict[str, str] + # Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]]) + # New format: { "displayName": { "url": "...", "index": 0 } } + # Old format: { "displayName": "url" } (for backward compatibility) + useful_links: Dict[str, Union[str, Dict[str, Any]]] class NewModelGroupRequest(BaseModel): diff --git a/litellm/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py index eeb1b10fe61..57d68771c7f 100644 --- a/litellm/types/proxy/public_endpoints/public_endpoints.py +++ b/litellm/types/proxy/public_endpoints/public_endpoints.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Literal, Optional +from typing import Dict, List, Literal, Optional, Union, Any from pydantic import BaseModel @@ -7,7 +7,10 @@ class PublicModelHubInfo(BaseModel): docs_title: str custom_docs_description: Optional[str] litellm_version: str - useful_links: Optional[Dict[str, str]] + # Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]]) + # New format: { "displayName": { "url": "...", "index": 0 } } + # Old format: { "displayName": "url" } (for backward compatibility) + useful_links: Optional[Dict[str, Union[str, Dict[str, Any]]]] class ProviderCredentialField(BaseModel): diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index acd448b3536..65c41c5aab4 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -221,7 +221,8 @@ export interface PublicModelHubInfo { docs_title: string; custom_docs_description: string | null; litellm_version: string; - useful_links: Record; + // Supports both old format (Record) and new format (Record) + useful_links: Record; } export interface LiteLLMWellKnownUiConfig { @@ -2362,7 +2363,10 @@ export const modelExceptionsCall = async ( } }; -export const updateUsefulLinksCall = async (accessToken: string, useful_links: Record) => { +export const updateUsefulLinksCall = async ( + accessToken: string, + useful_links: Record, +) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/model_hub/update_useful_links` : `/model_hub/update_useful_links`; const response = await fetch(url, { diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 6235a0c66f6..3493f0bf93f 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -97,7 +97,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded const [pageTitle, setPageTitle] = useState("LiteLLM Gateway"); const [customDocsDescription, setCustomDocsDescription] = useState(null); const [litellmVersion, setLitellmVersion] = useState(""); - const [usefulLinks, setUsefulLinks] = useState>({}); + const [usefulLinks, setUsefulLinks] = useState>({}); const [loading, setLoading] = useState(true); const [agentLoading, setAgentLoading] = useState(true); const [mcpLoading, setMcpLoading] = useState(true); @@ -976,16 +976,24 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded Useful Links
- {Object.entries(usefulLinks || {}).map(([title, url]) => ( - - ))} + {Object.entries(usefulLinks || {}) + .map(([title, value]) => { + // Handle both old format (string) and new format ({url, index}) + const url = typeof value === "string" ? value : value.url; + const index = typeof value === "string" ? 0 : value.index ?? 0; + return { title, url, index }; + }) + .sort((a, b) => a.index - b.index) + .map(({ title, url }) => ( + + ))}
)} diff --git a/ui/litellm-dashboard/src/components/useful_links_management.test.tsx b/ui/litellm-dashboard/src/components/useful_links_management.test.tsx index 7b1a40a499a..cf7c6083e2c 100644 --- a/ui/litellm-dashboard/src/components/useful_links_management.test.tsx +++ b/ui/litellm-dashboard/src/components/useful_links_management.test.tsx @@ -64,7 +64,9 @@ describe("UsefulLinksManagement", () => { await user.click(screen.getByRole("button", { name: /add link/i })); await waitFor(() => - expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", { Docs: "https://docs.example.com" }), + expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", { + Docs: { url: "https://docs.example.com", index: 0 }, + }), ); expect(await screen.findByText("Docs")).toBeInTheDocument(); @@ -98,9 +100,9 @@ describe("UsefulLinksManagement", () => { await waitFor(() => expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", { - "Second Link": "https://second.example.com", - "First Link": "https://first.example.com", - "Third Link": "https://third.example.com", + "Second Link": { url: "https://second.example.com", index: 0 }, + "First Link": { url: "https://first.example.com", index: 1 }, + "Third Link": { url: "https://third.example.com", index: 2 }, }), ); diff --git a/ui/litellm-dashboard/src/components/useful_links_management.tsx b/ui/litellm-dashboard/src/components/useful_links_management.tsx index a9367812f4b..19ef4605d87 100644 --- a/ui/litellm-dashboard/src/components/useful_links_management.tsx +++ b/ui/litellm-dashboard/src/components/useful_links_management.tsx @@ -16,6 +16,7 @@ interface Link { id: string; displayName: string; url: string; + index?: number; } const UsefulLinksManagement: React.FC = ({ accessToken, userRole }) => { @@ -38,11 +39,32 @@ const UsefulLinksManagement: React.FC = ({ accessTok const usefulLinks = response.useful_links || {}; // Convert object to array of links with ids - const linksArray = Object.entries(usefulLinks).map(([displayName, url], index) => ({ - id: `${index}-${displayName}`, - displayName, - url: url as string, - })); + // Handle both old format (Dict[str, str]) and new format (Dict[str, {url, index}]) + const linksArray = Object.entries(usefulLinks) + .map(([displayName, value]) => { + // Check if it's the new format with {url, index} + if (typeof value === "object" && value !== null && "url" in value) { + return { + id: `${(value as any).index ?? 0}-${displayName}`, + displayName, + url: (value as any).url as string, + index: (value as any).index ?? 0, + }; + } else { + // Old format: just a string URL + return { + id: `0-${displayName}`, + displayName, + url: value as string, + index: 0, + }; + } + }) + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + .map((link, index) => ({ + ...link, + id: `${index}-${link.displayName}`, + })); setLinks(linksArray); } else { @@ -69,10 +91,14 @@ const UsefulLinksManagement: React.FC = ({ accessTok if (!accessToken) return false; try { - // Convert array back to object format - const linksObject: Record = {}; - updatedLinks.forEach((link) => { - linksObject[link.displayName] = link.url; + // Convert array back to object format with index for ordering + // New format: { "displayName": { "url": "...", "index": 0 } } + const linksObject: Record = {}; + updatedLinks.forEach((link, index) => { + linksObject[link.displayName] = { + url: link.url, + index: index, + }; }); await updateUsefulLinksCall(accessToken, linksObject);