mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-15 04:32:22 +00:00
UI - support adding links to model hub (#12776)
* feat(model_hub_table.tsx): add ability for admin to add links to model hub allows admin to add model + key request access forms to model hub makes it easier to request access to specific models * refactor(ui/): cleanup ui - consistent styling * fix(useful_links_management.tsx): make tab collapsible and explain purpose * fix(ui/): fix ui linting errors * fix: fix linting error
This commit is contained in:
@@ -835,6 +835,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int(
|
||||
LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
|
||||
"default_internal_user_params",
|
||||
"public_model_groups",
|
||||
"public_model_groups_links",
|
||||
]
|
||||
SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"]
|
||||
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(
|
||||
|
||||
@@ -44,6 +44,9 @@ from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import create_object_audit_log
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
UpdateUsefulLinksRequest,
|
||||
)
|
||||
from litellm.types.router import (
|
||||
Deployment,
|
||||
DeploymentTypedDict,
|
||||
@@ -1035,6 +1038,73 @@ async def update_public_model_groups(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/model_hub/update_useful_links",
|
||||
description="Update useful links",
|
||||
tags=["model management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def update_useful_links(
|
||||
request: UpdateUsefulLinksRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update useful links.
|
||||
"""
|
||||
try:
|
||||
# Update the public model groups
|
||||
import litellm
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
# Check if user has admin permissions
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Only proxy admins can update public model groups. Your role={}".format(
|
||||
user_api_key_dict.user_role
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
litellm.public_model_groups_links = request.useful_links
|
||||
|
||||
# Load existing config
|
||||
config = await proxy_config.get_config()
|
||||
|
||||
# Update config with new settings
|
||||
if "litellm_settings" not in config:
|
||||
config["litellm_settings"] = {}
|
||||
|
||||
config["litellm_settings"]["public_model_groups_links"] = request.useful_links
|
||||
|
||||
# Save the updated config
|
||||
await proxy_config.save_config(new_config=config)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Updated useful links to: {request.useful_links} by user: {user_api_key_dict.user_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Successfully updated useful links",
|
||||
"useful_links": request.useful_links,
|
||||
"updated_by": user_api_key_dict.user_id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error updating public model groups: {str(e)}")
|
||||
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
|
||||
raise ProxyException(
|
||||
message=f"Error updating public model groups: {str(e)}",
|
||||
type=ProxyErrorTypes.internal_server_error,
|
||||
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
param=None,
|
||||
)
|
||||
|
||||
|
||||
def _deduplicate_litellm_router_models(models: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Deduplicate models based on their model_info.id field.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from pydantic import Field
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...router import ModelGroupInfo
|
||||
|
||||
|
||||
class ModelGroupInfoProxy(ModelGroupInfo):
|
||||
is_public_model_group: bool = Field(default=False)
|
||||
|
||||
|
||||
class UpdateUsefulLinksRequest(BaseModel):
|
||||
useful_links: Dict[str, str]
|
||||
|
||||
@@ -45,6 +45,7 @@ import SSOModals from "./SSOModals";
|
||||
import { ssoProviderConfigs } from './SSOModals';
|
||||
import SCIMConfig from "./SCIM";
|
||||
import UIAccessControlForm from "./UIAccessControlForm";
|
||||
import UsefulLinksManagement from "./useful_links_management";
|
||||
|
||||
interface AdminPanelProps {
|
||||
searchParams: any;
|
||||
@@ -54,6 +55,7 @@ interface AdminPanelProps {
|
||||
showSSOBanner: boolean;
|
||||
premiumUser: boolean;
|
||||
proxySettings?: any;
|
||||
userRole?: string | null;
|
||||
}
|
||||
import { useBaseUrl } from "./constants";
|
||||
|
||||
@@ -78,6 +80,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
||||
showSSOBanner,
|
||||
premiumUser,
|
||||
proxySettings,
|
||||
userRole,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [memberForm] = Form.useForm();
|
||||
@@ -551,6 +554,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
||||
<TabList>
|
||||
<Tab>Security Settings</Tab>
|
||||
<Tab>SCIM</Tab>
|
||||
<Tab>Useful Links</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
@@ -705,6 +709,12 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<UsefulLinksManagement
|
||||
accessToken={accessToken}
|
||||
userRole={userRole || null}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { modelHubColumns } from "./model_hub_table_columns";
|
||||
import PublicModelHub from "./public_model_hub";
|
||||
import MakeModelPublicForm from "./make_model_public_form";
|
||||
import ModelFilters from "./model_filters";
|
||||
import UsefulLinksManagement from "./useful_links_management";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
@@ -236,30 +237,46 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<ModelFilters
|
||||
modelHubData={modelHubData || []}
|
||||
onFilteredDataChange={handleFilteredDataChange}
|
||||
/>
|
||||
|
||||
{/* Model Table */}
|
||||
<ModelDataTable
|
||||
columns={modelHubColumns(
|
||||
showModal,
|
||||
copyToClipboard,
|
||||
publicPage,
|
||||
)}
|
||||
data={filteredData}
|
||||
isLoading={loading}
|
||||
table={tableRef}
|
||||
defaultSorting={[{ id: "model_group", desc: false }]}
|
||||
/>
|
||||
{/* Useful Links Management Section for Admins */}
|
||||
{isAdminRole(userRole || "") && (
|
||||
<div className="mt-8 mb-2">
|
||||
<UsefulLinksManagement
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Filters and Table */}
|
||||
<Card>
|
||||
{/* Filters */}
|
||||
<ModelFilters
|
||||
modelHubData={modelHubData || []}
|
||||
onFilteredDataChange={handleFilteredDataChange}
|
||||
/>
|
||||
|
||||
{/* Model Table */}
|
||||
<ModelDataTable
|
||||
columns={modelHubColumns(
|
||||
showModal,
|
||||
copyToClipboard,
|
||||
publicPage,
|
||||
)}
|
||||
data={filteredData}
|
||||
isLoading={loading}
|
||||
table={tableRef}
|
||||
defaultSorting={[{ id: "model_group", desc: false }]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4 text-center space-y-2">
|
||||
<Text className="text-sm text-gray-600">
|
||||
Showing {filteredData.length} of {modelHubData?.length || 0} models
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<Card className="mx-auto max-w-xl mt-10">
|
||||
|
||||
@@ -2104,6 +2104,29 @@ export const modelExceptionsCall = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUsefulLinksCall = async (accessToken: String, useful_links: Record<string, string>) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/model_hub/update_useful_links` : `/model_hub/update_useful_links`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ useful_links: useful_links }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to create key:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const modelAvailableCall = async (
|
||||
accessToken: String,
|
||||
userID: String,
|
||||
@@ -6367,3 +6390,5 @@ export const vectorStoreSearchCall = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { message, Modal } from "antd";
|
||||
import { PlusCircleIcon, PencilIcon, TrashIcon, 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";
|
||||
|
||||
interface UsefulLinksManagementProps {
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
id: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({
|
||||
accessToken,
|
||||
userRole,
|
||||
}) => {
|
||||
const [links, setLinks] = useState<Link[]>([]);
|
||||
const [newLink, setNewLink] = useState({ url: "", displayName: "" });
|
||||
const [editingLink, setEditingLink] = useState<Link | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const fetchUsefulLinks = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getPublicModelHubInfo();
|
||||
|
||||
if (response && response.useful_links) {
|
||||
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,
|
||||
}));
|
||||
|
||||
setLinks(linksArray);
|
||||
} else {
|
||||
setLinks([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching useful links:", error);
|
||||
setLinks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsefulLinks();
|
||||
}, [accessToken]);
|
||||
|
||||
// Check if user is admin
|
||||
if (!isAdminRole(userRole || "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const saveLinksToBackend = async (updatedLinks: Link[]) => {
|
||||
if (!accessToken) return false;
|
||||
|
||||
try {
|
||||
// Convert array back to object format
|
||||
const linksObject: Record<string, string> = {};
|
||||
updatedLinks.forEach(link => {
|
||||
linksObject[link.displayName] = link.url;
|
||||
});
|
||||
|
||||
await updateUsefulLinksCall(accessToken, linksObject);
|
||||
// show success modal with public model hub link
|
||||
Modal.success({
|
||||
title: "Links Saved Successfully",
|
||||
content: (
|
||||
<div className="py-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Your useful links have been saved and are now visible on the public model hub.
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 mb-2 font-medium">
|
||||
View your updated model hub:
|
||||
</p>
|
||||
<a
|
||||
href={`${getProxyBaseUrl()}/ui/model_hub_table`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 underline text-sm font-medium"
|
||||
>
|
||||
Open Public Model Hub →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
width: 500,
|
||||
okText: "Close",
|
||||
maskClosable: true,
|
||||
keyboard: true
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving links:", error);
|
||||
message.error(`Failed to save links - ${error}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!newLink.url || !newLink.displayName) return;
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(newLink.url);
|
||||
} catch {
|
||||
message.error("Please enter a valid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate display names
|
||||
if (links.some(link => link.displayName === newLink.displayName)) {
|
||||
message.error("A link with this display name already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const newLinkObj: Link = {
|
||||
id: `${Date.now()}-${newLink.displayName}`,
|
||||
displayName: newLink.displayName,
|
||||
url: newLink.url,
|
||||
};
|
||||
|
||||
const updatedLinks = [...links, newLinkObj];
|
||||
|
||||
if (await saveLinksToBackend(updatedLinks)) {
|
||||
setLinks(updatedLinks);
|
||||
setNewLink({ url: "", displayName: "" });
|
||||
message.success("Link added successfully");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditLink = (link: Link) => {
|
||||
setEditingLink({ ...link });
|
||||
};
|
||||
|
||||
const handleUpdateLink = async () => {
|
||||
if (!editingLink) return;
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(editingLink.url);
|
||||
} catch {
|
||||
message.error("Please enter a valid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate display names (excluding current link)
|
||||
if (links.some(link => link.id !== editingLink.id && link.displayName === editingLink.displayName)) {
|
||||
message.error("A link with this display name already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedLinks = links.map(link =>
|
||||
link.id === editingLink.id ? editingLink : link
|
||||
);
|
||||
|
||||
if (await saveLinksToBackend(updatedLinks)) {
|
||||
setLinks(updatedLinks);
|
||||
setEditingLink(null);
|
||||
message.success("Link updated successfully");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingLink(null);
|
||||
};
|
||||
|
||||
const deleteLink = async (linkId: string) => {
|
||||
const updatedLinks = links.filter(link => link.id !== linkId);
|
||||
|
||||
if (await saveLinksToBackend(updatedLinks)) {
|
||||
setLinks(updatedLinks);
|
||||
message.success("Link deleted successfully");
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentLink = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Title className="mb-0">Link Management</Title>
|
||||
<p className="text-sm text-gray-500">Manage the links that are displayed under 'Useful Links' on the public model hub.</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-6">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Link</Text>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newLink.url}
|
||||
onChange={(e) =>
|
||||
setNewLink({
|
||||
...newLink,
|
||||
url: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newLink.displayName}
|
||||
onChange={(e) =>
|
||||
setNewLink({
|
||||
...newLink,
|
||||
displayName: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Friendly name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleAddLink}
|
||||
disabled={!newLink.url || !newLink.displayName}
|
||||
className={`flex items-center px-4 py-2 rounded-md text-sm ${!newLink.url || !newLink.displayName ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-green-600 text-white hover:bg-green-700'}`}
|
||||
>
|
||||
<PlusCircleIcon className="w-4 h-4 mr-1" />
|
||||
Add Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">
|
||||
Manage Existing Links
|
||||
</Text>
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
Display Name
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
URL
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
Actions
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links.map((link) => (
|
||||
<TableRow key={link.id} className="h-8">
|
||||
{editingLink && editingLink.id === link.id ? (
|
||||
<>
|
||||
<TableCell className="py-0.5">
|
||||
<input
|
||||
type="text"
|
||||
value={editingLink.displayName}
|
||||
onChange={(e) =>
|
||||
setEditingLink({
|
||||
...editingLink,
|
||||
displayName: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5">
|
||||
<input
|
||||
type="text"
|
||||
value={editingLink.url}
|
||||
onChange={(e) =>
|
||||
setEditingLink({
|
||||
...editingLink,
|
||||
url: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 whitespace-nowrap">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleUpdateLink}
|
||||
className="text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="text-xs bg-gray-50 text-gray-600 px-2 py-1 rounded hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="py-0.5 text-sm text-gray-900">
|
||||
{link.displayName}
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 text-sm text-gray-500">
|
||||
{link.url}
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 whitespace-nowrap">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentLink(link.url)}
|
||||
className="text-xs bg-green-50 text-green-600 px-2 py-1 rounded hover:bg-green-100"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditLink(link)}
|
||||
className="text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100"
|
||||
>
|
||||
<PencilIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteLink(link.id)}
|
||||
className="text-xs bg-red-50 text-red-600 px-2 py-1 rounded hover:bg-red-100"
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
{links.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="py-0.5 text-sm text-gray-500 text-center"
|
||||
>
|
||||
No links added yet. Add a new link above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsefulLinksManagement;
|
||||
Reference in New Issue
Block a user