mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #17859 from BerriAI/litellm_ui_links_rearrange
[Feature] Model Hub Useful Links Rearrange
This commit is contained in:
commit
cf5dab7f52
9 changed files with 238 additions and 73 deletions
|
|
@ -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" = (
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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<string, TableIconActionButtonBaseP
|
|||
Delete: { icon: TrashIcon, className: "hover:text-red-600" },
|
||||
Test: { icon: PlayIcon, className: "hover:text-blue-600" },
|
||||
Regenerate: { icon: RefreshIcon, className: "hover:text-green-600" },
|
||||
Up: { icon: ChevronUpIcon, className: "hover:text-blue-600" },
|
||||
Down: { icon: ChevronDownIcon, className: "hover:text-blue-600" },
|
||||
Open: { icon: ExternalLinkIcon, className: "hover:text-green-600" },
|
||||
};
|
||||
|
||||
export default function TableIconActionButton({
|
||||
|
|
|
|||
|
|
@ -221,7 +221,8 @@ export interface PublicModelHubInfo {
|
|||
docs_title: string;
|
||||
custom_docs_description: string | null;
|
||||
litellm_version: string;
|
||||
useful_links: Record<string, string>;
|
||||
// Supports both old format (Record<string, string>) and new format (Record<string, {url: string, index: number}>)
|
||||
useful_links: Record<string, string | { url: string; index: number }>;
|
||||
}
|
||||
|
||||
export interface LiteLLMWellKnownUiConfig {
|
||||
|
|
@ -2362,7 +2363,10 @@ export const modelExceptionsCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const updateUsefulLinksCall = async (accessToken: string, useful_links: Record<string, string>) => {
|
||||
export const updateUsefulLinksCall = async (
|
||||
accessToken: string,
|
||||
useful_links: Record<string, string | { url: string; index: number }>,
|
||||
) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/model_hub/update_useful_links` : `/model_hub/update_useful_links`;
|
||||
const response = await fetch(url, {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
const [pageTitle, setPageTitle] = useState<string>("LiteLLM Gateway");
|
||||
const [customDocsDescription, setCustomDocsDescription] = useState<string | null>(null);
|
||||
const [litellmVersion, setLitellmVersion] = useState<string>("");
|
||||
const [usefulLinks, setUsefulLinks] = useState<Record<string, string>>({});
|
||||
const [usefulLinks, setUsefulLinks] = useState<Record<string, string | { url: string; index: number }>>({});
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [agentLoading, setAgentLoading] = useState<boolean>(true);
|
||||
const [mcpLoading, setMcpLoading] = useState<boolean>(true);
|
||||
|
|
@ -976,16 +976,24 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
<Card className="mb-10 p-8 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<Title className="text-2xl font-semibold mb-6 text-gray-900">Useful Links</Title>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Object.entries(usefulLinks || {}).map(([title, url]) => (
|
||||
<button
|
||||
key={title}
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
className="flex items-center space-x-3 text-blue-600 hover:text-blue-800 transition-colors p-3 rounded-lg hover:bg-blue-50 border border-gray-200"
|
||||
>
|
||||
<ExternalLinkIcon className="w-4 h-4" />
|
||||
<Text className="text-sm font-medium">{title}</Text>
|
||||
</button>
|
||||
))}
|
||||
{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 }) => (
|
||||
<button
|
||||
key={title}
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
className="flex items-center space-x-3 text-blue-600 hover:text-blue-800 transition-colors p-3 rounded-lg hover:bg-blue-50 border border-gray-200"
|
||||
>
|
||||
<ExternalLinkIcon className="w-4 h-4" />
|
||||
<Text className="text-sm font-medium">{title}</Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -24,23 +24,17 @@ export const searchToolColumns = (
|
|||
{
|
||||
accessorKey: "search_tool_name",
|
||||
header: "Name",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-medium">{getValue() as string}</span>
|
||||
),
|
||||
cell: ({ getValue }) => <span className="font-medium">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<span className="text-sm">
|
||||
{displayName}
|
||||
</span>
|
||||
);
|
||||
|
||||
return <span className="text-sm">{displayName}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -49,11 +43,7 @@ export const searchToolColumns = (
|
|||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const tool = row.original;
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -62,11 +52,7 @@ export const searchToolColumns = (
|
|||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const tool = row.original;
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -90,4 +76,3 @@ export const searchToolColumns = (
|
|||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -64,11 +64,48 @@ 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();
|
||||
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(<UsefulLinksManagement accessToken="token" userRole="Admin" />);
|
||||
|
||||
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": { 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 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockedNotifications.success).toHaveBeenCalledWith("Link order saved successfully");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -15,6 +16,7 @@ interface Link {
|
|||
id: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessToken, userRole }) => {
|
||||
|
|
@ -23,6 +25,8 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
const [editingLink, setEditingLink] = useState<Link | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isRearranging, setIsRearranging] = useState(false);
|
||||
const [originalLinksOrder, setOriginalLinksOrder] = useState<Link[]>([]);
|
||||
|
||||
const fetchUsefulLinks = async () => {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -35,11 +39,32 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ 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 {
|
||||
|
|
@ -66,10 +91,14 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
if (!accessToken) return false;
|
||||
|
||||
try {
|
||||
// Convert array back to object format
|
||||
const linksObject: Record<string, string> = {};
|
||||
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<string, { url: string; index: number }> = {};
|
||||
updatedLinks.forEach((link, index) => {
|
||||
linksObject[link.displayName] = {
|
||||
url: link.url,
|
||||
index: index,
|
||||
};
|
||||
});
|
||||
|
||||
await updateUsefulLinksCall(accessToken, linksObject);
|
||||
|
|
@ -187,6 +216,42 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ 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 (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
|
|
@ -252,7 +317,32 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Links</Text>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Text className="text-sm font-medium text-gray-700">Manage Existing Links</Text>
|
||||
{!isRearranging ? (
|
||||
<button
|
||||
onClick={handleStartRearranging}
|
||||
className="text-xs bg-purple-50 text-purple-600 px-3 py-1.5 rounded hover:bg-purple-100 flex items-center"
|
||||
>
|
||||
Rearrange Order
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSaveRearranging}
|
||||
className="text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700"
|
||||
>
|
||||
Save Order
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRearranging}
|
||||
className="text-xs bg-gray-50 text-gray-600 px-3 py-1.5 rounded hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
|
|
@ -264,7 +354,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links.map((link) => (
|
||||
{links.map((link, index) => (
|
||||
<TableRow key={link.id} className="h-8">
|
||||
{editingLink && editingLink.id === link.id ? (
|
||||
<>
|
||||
|
|
@ -316,26 +406,47 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
<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>
|
||||
{isRearranging ? (
|
||||
<div className="flex space-x-2">
|
||||
<TableIconActionButton
|
||||
variant="Up"
|
||||
onClick={() => handleMoveUp(index)}
|
||||
tooltipText="Move up"
|
||||
disabled={index === 0}
|
||||
disabledTooltipText="Already at the top"
|
||||
dataTestId={`move-up-${link.id}`}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Down"
|
||||
onClick={() => handleMoveDown(index)}
|
||||
tooltipText="Move down"
|
||||
disabled={index === links.length - 1}
|
||||
disabledTooltipText="Already at the bottom"
|
||||
dataTestId={`move-down-${link.id}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<TableIconActionButton
|
||||
variant="Open"
|
||||
onClick={() => setCurrentLink(link.url)}
|
||||
tooltipText="Open link"
|
||||
dataTestId={`open-link-${link.id}`}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
onClick={() => handleEditLink(link)}
|
||||
tooltipText="Edit link"
|
||||
dataTestId={`edit-link-${link.id}`}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
onClick={() => deleteLink(link.id)}
|
||||
tooltipText="Delete link"
|
||||
dataTestId={`delete-link-${link.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue