mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Fix model management page UI improvements
- Delete button now shows confirmation modal instead of redirecting to detail page - Entire table row is clickable to view model details - Fixed Last Refreshed text alignment and shortened time format - Added dismiss button (X) to Missing provider banner with localStorage persistence - Added compact Request Provider button in header when banner is dismissed
This commit is contained in:
parent
6e9210381c
commit
219cd414f3
4 changed files with 160 additions and 47 deletions
|
|
@ -15,7 +15,7 @@ import { transformModelData } from "./utils/modelDataTransformer";
|
|||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
|
||||
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import type { UploadProps } from "antd";
|
||||
import { Form, Typography } from "antd";
|
||||
import { PlusCircleOutlined } from "@ant-design/icons";
|
||||
|
|
@ -62,6 +62,12 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
const [showMissingProviderBanner, setShowMissingProviderBanner] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("hideMissingProviderBanner") !== "true";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo();
|
||||
|
|
@ -153,7 +159,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
|
||||
const handleRefreshClick = () => {
|
||||
const currentDate = new Date();
|
||||
setLastRefreshed(currentDate.toLocaleString());
|
||||
setLastRefreshed(currentDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
refetchModels();
|
||||
};
|
||||
|
|
@ -275,43 +281,75 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
)}
|
||||
</div>
|
||||
{!showMissingProviderBanner && (
|
||||
<a
|
||||
href="https://models.litellm.ai/?request=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-[#6366f1] hover:text-[#5558e3] border border-[#6366f1] hover:border-[#5558e3] rounded-lg transition-colors"
|
||||
>
|
||||
<PlusCircleOutlined style={{ fontSize: "12px" }} />
|
||||
Request Provider
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Missing Provider Banner */}
|
||||
<div className="mb-4 px-4 py-3 bg-blue-50 rounded-lg border border-blue-100 flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-white rounded-full flex items-center justify-center border border-blue-200">
|
||||
<PlusCircleOutlined style={{ fontSize: "18px", color: "#6366f1" }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-gray-900 font-semibold text-sm m-0">Missing a provider?</h4>
|
||||
<p className="text-gray-500 text-xs m-0 mt-0.5">
|
||||
The LiteLLM engineering team is constantly adding support for new LLM models, providers, endpoints. If
|
||||
you don't see the one you need, let us know and we'll prioritize it.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://models.litellm.ai/?request=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-[#6366f1] hover:bg-[#5558e3] text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Request Provider
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{showMissingProviderBanner && (
|
||||
<div className="mb-4 px-4 py-3 bg-blue-50 rounded-lg border border-blue-100 flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-white rounded-full flex items-center justify-center border border-blue-200">
|
||||
<PlusCircleOutlined style={{ fontSize: "18px", color: "#6366f1" }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-gray-900 font-semibold text-sm m-0">Missing a provider?</h4>
|
||||
<p className="text-gray-500 text-xs m-0 mt-0.5">
|
||||
The LiteLLM engineering team is constantly adding support for new LLM models, providers, endpoints. If
|
||||
you don't see the one you need, let us know and we'll prioritize it.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://models.litellm.ai/?request=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-[#6366f1] hover:bg-[#5558e3] text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
Request Provider
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMissingProviderBanner(false);
|
||||
localStorage.setItem("hideMissingProviderBanner", "true");
|
||||
}}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedModelId && !isLoading ? (
|
||||
<ModelInfoView
|
||||
modelId={selectedModelId}
|
||||
|
|
@ -341,13 +379,13 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
{all_admin_roles.includes(userRole) && <Tab>Price Data Reload</Tab>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<Icon
|
||||
icon={RefreshIcon} // Modify as necessary for correct icon name
|
||||
icon={RefreshIcon}
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="self-center"
|
||||
className="cursor-pointer"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import { Team } from "@/components/key_team_helpers/key_list";
|
|||
import { AllModelsDataTable } from "@/components/model_dashboard/all_models_table";
|
||||
import { columns } from "@/components/molecules/models/columns";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { modelDeleteCall } from "@/components/networking";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Grid, Select, SelectItem, TabPanel, Text } from "@tremor/react";
|
||||
import { Skeleton, Spin } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
|
|
@ -33,8 +37,9 @@ const AllModelsTab = ({
|
|||
setSelectedTeamId,
|
||||
}: AllModelsTabProps) => {
|
||||
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
|
||||
const { userId, userRole, premiumUser } = useAuthorized();
|
||||
const { accessToken, userId, userRole, premiumUser } = useAuthorized();
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [modelNameSearch, setModelNameSearch] = useState<string>("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||
|
|
@ -92,7 +97,7 @@ const AllModelsTab = ({
|
|||
return sort.desc ? "desc" : "asc";
|
||||
}, [sorting]);
|
||||
|
||||
const { data: rawModelData, isLoading: isLoadingModelsInfo } = useModelsInfo(
|
||||
const { data: rawModelData, isLoading: isLoadingModelsInfo, refetch: refetchModels } = useModelsInfo(
|
||||
currentPage,
|
||||
pageSize,
|
||||
debouncedSearch || undefined,
|
||||
|
|
@ -117,6 +122,9 @@ const AllModelsTab = ({
|
|||
return transformModelData(rawModelData, getProviderFromModel);
|
||||
}, [rawModelData, modelCostMapData]);
|
||||
|
||||
const [deleteModalModelId, setDeleteModalModelId] = useState<string | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// Get pagination metadata from the response
|
||||
const paginationMeta = useMemo(() => {
|
||||
if (!rawModelData) {
|
||||
|
|
@ -187,6 +195,28 @@ const AllModelsTab = ({
|
|||
setSorting([]);
|
||||
};
|
||||
|
||||
const modelToDelete = useMemo(() => {
|
||||
if (!deleteModalModelId || !modelData?.data) return null;
|
||||
return modelData.data.find((model: any) => model.model_info.id === deleteModalModelId);
|
||||
}, [deleteModalModelId, modelData]);
|
||||
|
||||
const handleDeleteModel = async () => {
|
||||
if (!accessToken || !deleteModalModelId) return;
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await modelDeleteCall(accessToken, deleteModalModelId);
|
||||
NotificationsManager.success("Model deleted successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
refetchModels();
|
||||
} catch (error) {
|
||||
console.error("Error deleting model:", error);
|
||||
NotificationsManager.fromBackend("Failed to delete model");
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
setDeleteModalModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<Grid>
|
||||
|
|
@ -481,6 +511,7 @@ const AllModelsTab = ({
|
|||
() => { },
|
||||
expandedRows,
|
||||
setExpandedRows,
|
||||
setDeleteModalModelId,
|
||||
)}
|
||||
data={filteredData}
|
||||
isLoading={isLoadingModelsInfo}
|
||||
|
|
@ -489,10 +520,40 @@ const AllModelsTab = ({
|
|||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
enablePagination={true}
|
||||
onRowClick={(model: any) => setSelectedModelId(model.model_info.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={!!deleteModalModelId}
|
||||
title="Delete Model"
|
||||
alertMessage="This action cannot be undone."
|
||||
message="Are you sure you want to delete this model?"
|
||||
resourceInformationTitle="Model Information"
|
||||
resourceInformation={modelToDelete ? [
|
||||
{
|
||||
label: "Model Name",
|
||||
value: modelToDelete.model_name || "Not Set",
|
||||
},
|
||||
{
|
||||
label: "LiteLLM Model Name",
|
||||
value: modelToDelete.litellm_model_name || "Not Set",
|
||||
},
|
||||
{
|
||||
label: "Provider",
|
||||
value: modelToDelete.provider || "Not Set",
|
||||
},
|
||||
{
|
||||
label: "Created By",
|
||||
value: modelToDelete.model_info?.created_by || "Not Set",
|
||||
},
|
||||
] : []}
|
||||
onCancel={() => setDeleteModalModelId(null)}
|
||||
onOk={handleDeleteModel}
|
||||
confirmLoading={deleteLoading}
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface ModelDataTableProps<TData, TValue> {
|
|||
pagination?: PaginationState;
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
enablePagination?: boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
export function ModelDataTable<TData, TValue>({
|
||||
|
|
@ -40,6 +41,7 @@ export function ModelDataTable<TData, TValue>({
|
|||
pagination,
|
||||
onPaginationChange,
|
||||
enablePagination = false,
|
||||
onRowClick,
|
||||
}: ModelDataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>(defaultSorting);
|
||||
const [columnResizeMode] = React.useState<ColumnResizeMode>("onChange");
|
||||
|
|
@ -157,7 +159,11 @@ export function ModelDataTable<TData, TValue>({
|
|||
</TableRow>
|
||||
) : tableInstance.getRowModel().rows.length > 0 ? (
|
||||
tableInstance.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const columns = (
|
|||
handleRefreshClick: () => void,
|
||||
expandedRows: Set<string>,
|
||||
setExpandedRows: (expandedRows: Set<string>) => void,
|
||||
onDeleteClick?: (modelId: string) => void,
|
||||
): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model ID</span>,
|
||||
|
|
@ -27,7 +28,10 @@ export const columns = (
|
|||
<Tooltip title={model.model_info.id}>
|
||||
<div
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
onClick={() => setSelectedModelId(model.model_info.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedModelId(model.model_info.id);
|
||||
}}
|
||||
>
|
||||
{model.model_info.id}
|
||||
</div>
|
||||
|
|
@ -195,7 +199,10 @@ export const columns = (
|
|||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => setSelectedTeamId(model.model_info.team_id)}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTeamId(model.model_info.team_id);
|
||||
}}
|
||||
>
|
||||
{model.model_info.team_id.slice(0, 7)}...
|
||||
</Button>
|
||||
|
|
@ -300,9 +307,10 @@ export const columns = (
|
|||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (canEditModel) {
|
||||
setSelectedModelId(model.model_info.id);
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditModel && onDeleteClick) {
|
||||
onDeleteClick(model.model_info.id);
|
||||
}
|
||||
}}
|
||||
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue