mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
[Feat] UI: Allow Admins to control what pages are visible on LeftNav (#19907)
* feat: enabled_ui_pages_internal_users * init ui for internal user controsl * fix ui settings * fix build * fix leftnav * fix leftnav * test fixes * fix leftnav * isPageAccessibleToInternalUsers * docs fix * docs ui viz
This commit is contained in:
parent
9a245031bd
commit
3080e04180
12 changed files with 827 additions and 37 deletions
121
docs/my-website/docs/proxy/ui/page_visibility.md
Normal file
121
docs/my-website/docs/proxy/ui/page_visibility.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import Image from '@theme/IdealImage';
|
||||
|
||||
# Control Page Visibility for Internal Users
|
||||
|
||||
Configure which navigation tabs and pages are visible to internal users (non-admin developers) in the LiteLLM UI.
|
||||
|
||||
Use this feature to simplify the UI and control which pages your internal users/developers can see when signing in.
|
||||
|
||||
## Overview
|
||||
|
||||
By default, all pages accessible to internal users are visible in the navigation sidebar. The page visibility control allows admins to restrict which pages internal users can see, creating a more focused and streamlined experience.
|
||||
|
||||
|
||||
## Configure Page Visibility
|
||||
|
||||
### 1. Navigate to Settings
|
||||
|
||||
Click the **Settings** icon in the sidebar.
|
||||
|
||||

|
||||
|
||||
### 2. Go to Admin Settings
|
||||
|
||||
Click **Admin Settings** from the settings menu.
|
||||
|
||||

|
||||
|
||||
### 3. Select UI Settings
|
||||
|
||||
Click **UI Settings** to access the page visibility controls.
|
||||
|
||||

|
||||
|
||||
### 4. Open Page Visibility Configuration
|
||||
|
||||
Click **Configure Page Visibility** to expand the configuration panel.
|
||||
|
||||

|
||||
|
||||
### 5. Select Pages to Make Visible
|
||||
|
||||
Check the boxes for the pages you want internal users to see. Pages are organized by category for easy navigation.
|
||||
|
||||

|
||||
|
||||
**Available pages include:**
|
||||
- Virtual Keys
|
||||
- Playground
|
||||
- Models + Endpoints
|
||||
- Agents
|
||||
- MCP Servers
|
||||
- Search Tools
|
||||
- Vector Stores
|
||||
- Logs
|
||||
- Teams
|
||||
- Organizations
|
||||
- Usage
|
||||
- Budgets
|
||||
- And more...
|
||||
|
||||
### 6. Save Your Configuration
|
||||
|
||||
Click **Save Page Visibility Settings** to apply the changes.
|
||||
|
||||

|
||||
|
||||
### 7. Verify Changes
|
||||
|
||||
Internal users will now only see the selected pages in their navigation sidebar.
|
||||
|
||||

|
||||
|
||||
## Reset to Default
|
||||
|
||||
To restore all pages to internal users:
|
||||
|
||||
1. Open the Page Visibility configuration
|
||||
2. Click **Reset to Default (All Pages)**
|
||||
3. Click **Save Page Visibility Settings**
|
||||
|
||||
This will clear the restriction and show all accessible pages to internal users.
|
||||
|
||||
## API Configuration
|
||||
|
||||
You can also configure page visibility programmatically using the API:
|
||||
|
||||
### Get Current Settings
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://localhost:4000/ui_settings/get' \
|
||||
-H 'Authorization: Bearer <your-admin-key>'
|
||||
```
|
||||
|
||||
### Update Page Visibility
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'http://localhost:4000/ui_settings/update' \
|
||||
-H 'Authorization: Bearer <your-admin-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"enabled_ui_pages_internal_users": [
|
||||
"api-keys",
|
||||
"agents",
|
||||
"mcp-servers",
|
||||
"logs",
|
||||
"teams"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Clear Page Visibility Restrictions
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'http://localhost:4000/ui_settings/update' \
|
||||
-H 'Authorization: Bearer <your-admin-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"enabled_ui_pages_internal_users": null
|
||||
}'
|
||||
```
|
||||
|
||||
|
|
@ -828,6 +828,7 @@ asyncio.run(router_acompletion())
|
|||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Traffic Mirroring / Silent Experiments
|
||||
|
||||
|
|
|
|||
|
|
@ -274,11 +274,19 @@ const sidebars = {
|
|||
"proxy/custom_sso",
|
||||
"proxy/ai_hub",
|
||||
"proxy/model_compare_ui",
|
||||
"proxy/public_teams",
|
||||
"proxy/self_serve",
|
||||
"proxy/ui/bulk_edit_users",
|
||||
"proxy/ui_credentials",
|
||||
"tutorials/scim_litellm",
|
||||
{
|
||||
type: "category",
|
||||
label: "UI User/Team Management",
|
||||
items: [
|
||||
"proxy/access_control",
|
||||
"proxy/public_teams",
|
||||
"proxy/self_serve",
|
||||
"proxy/ui/bulk_edit_users",
|
||||
"proxy/ui/page_visibility",
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "UI Usage Tracking",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ class UISettings(BaseModel):
|
|||
description="Prevents Team Admins from deleting users from the teams they manage. Useful for SCIM provisioning where team membership is defined externally.",
|
||||
)
|
||||
|
||||
enabled_ui_pages_internal_users: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="List of page keys that internal users (non-admins) can see in the UI sidebar. If not set, all pages are visible based on role permissions.",
|
||||
)
|
||||
|
||||
|
||||
class UISettingsResponse(SettingsResponse):
|
||||
"""Response model for UI settings"""
|
||||
|
|
@ -89,6 +94,7 @@ class UISettingsResponse(SettingsResponse):
|
|||
ALLOWED_UI_SETTINGS_FIELDS = {
|
||||
"disable_model_add_for_internal_users",
|
||||
"disable_team_admin_delete_team_user",
|
||||
"enabled_ui_pages_internal_users",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import Sidebar from "@/components/leftnav";
|
||||
import { getUISettings } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface SidebarProviderProps {
|
||||
setPage: (page: string) => void;
|
||||
|
|
@ -7,7 +12,44 @@ interface SidebarProviderProps {
|
|||
}
|
||||
|
||||
const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => {
|
||||
return <Sidebar setPage={setPage} defaultSelectedKey={defaultSelectedKey} collapsed={sidebarCollapsed} />;
|
||||
const { accessToken } = useAuthorized();
|
||||
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUISettings = async () => {
|
||||
if (!accessToken) {
|
||||
console.log("[SidebarProvider] No access token, skipping UI settings fetch");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[SidebarProvider] Fetching UI settings from /get/ui_settings");
|
||||
const settings = await getUISettings(accessToken);
|
||||
console.log("[SidebarProvider] UI settings response:", settings);
|
||||
|
||||
// API returns 'values' not 'settings'
|
||||
if (settings?.values?.enabled_ui_pages_internal_users !== undefined) {
|
||||
console.log("[SidebarProvider] Setting enabled pages:", settings.values.enabled_ui_pages_internal_users);
|
||||
setEnabledPagesInternalUsers(settings.values.enabled_ui_pages_internal_users);
|
||||
} else {
|
||||
console.log("[SidebarProvider] No enabled_ui_pages_internal_users in response (all pages visible by default)");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SidebarProvider] Failed to fetch UI settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUISettings();
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
setPage={setPage}
|
||||
defaultSelectedKey={defaultSelectedKey}
|
||||
collapsed={sidebarCollapsed}
|
||||
enabledPagesInternalUsers={enabledPagesInternalUsers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarProvider;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { getAvailablePages } from "@/components/page_utils";
|
||||
import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface PageVisibilitySettingsProps {
|
||||
enabledPagesInternalUsers: string[] | null | undefined;
|
||||
enabledPagesPropertyDescription?: string;
|
||||
isUpdating: boolean;
|
||||
onUpdate: (settings: { enabled_ui_pages_internal_users: string[] | null }) => void;
|
||||
}
|
||||
|
||||
export default function PageVisibilitySettings({
|
||||
enabledPagesInternalUsers,
|
||||
enabledPagesPropertyDescription,
|
||||
isUpdating,
|
||||
onUpdate,
|
||||
}: PageVisibilitySettingsProps) {
|
||||
// Check if page visibility is set (null/undefined means "not set" = all pages visible)
|
||||
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
|
||||
|
||||
// Get available pages from leftnav configuration
|
||||
const availablePages = useMemo(() => getAvailablePages(), []);
|
||||
|
||||
// Group pages by their group for better UI
|
||||
const pagesByGroup = useMemo(() => {
|
||||
const grouped: Record<string, typeof availablePages> = {};
|
||||
availablePages.forEach((page) => {
|
||||
if (!grouped[page.group]) {
|
||||
grouped[page.group] = [];
|
||||
}
|
||||
grouped[page.group].push(page);
|
||||
});
|
||||
return grouped;
|
||||
}, [availablePages]);
|
||||
|
||||
// Local state for page selection
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>(enabledPagesInternalUsers || []);
|
||||
|
||||
// Update local state when data changes
|
||||
useMemo(() => {
|
||||
if (enabledPagesInternalUsers) {
|
||||
setSelectedPages(enabledPagesInternalUsers);
|
||||
} else {
|
||||
setSelectedPages([]);
|
||||
}
|
||||
}, [enabledPagesInternalUsers]);
|
||||
|
||||
const handleSavePageVisibility = () => {
|
||||
onUpdate({ enabled_ui_pages_internal_users: selectedPages.length > 0 ? selectedPages : null });
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
setSelectedPages([]);
|
||||
onUpdate({ enabled_ui_pages_internal_users: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Space align="center">
|
||||
<Typography.Text strong>Internal User Page Visibility</Typography.Text>
|
||||
{!isPageVisibilitySet && (
|
||||
<Tag color="default" style={{ marginLeft: "8px" }}>
|
||||
Not set (all pages visible)
|
||||
</Tag>
|
||||
)}
|
||||
{isPageVisibilitySet && (
|
||||
<Tag color="blue" style={{ marginLeft: "8px" }}>
|
||||
{selectedPages.length} page{selectedPages.length !== 1 ? "s" : ""} selected
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{enabledPagesPropertyDescription && (
|
||||
<Typography.Text type="secondary">{enabledPagesPropertyDescription}</Typography.Text>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: "12px", fontStyle: "italic" }}>
|
||||
By default, all pages are visible to internal users. Select specific pages to restrict visibility.
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: "12px", color: "#8b5cf6" }}>
|
||||
Note: Only pages accessible to internal user roles are shown here. Admin-only pages are excluded as they
|
||||
cannot be made visible to internal users regardless of this setting.
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: "page-visibility",
|
||||
label: "Configure Page Visibility",
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Checkbox.Group value={selectedPages} onChange={setSelectedPages} style={{ width: "100%" }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{Object.entries(pagesByGroup).map(([groupName, pages]) => (
|
||||
<div key={groupName}>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#6b7280",
|
||||
letterSpacing: "0.05em",
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size="small" style={{ marginLeft: "16px", width: "100%" }}>
|
||||
{pages.map((page) => (
|
||||
<div key={page.page} style={{ marginBottom: "4px" }}>
|
||||
<Checkbox value={page.page}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{page.label}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{page.description}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSavePageVisibility} loading={isUpdating} disabled={isUpdating}>
|
||||
Save Page Visibility Settings
|
||||
</Button>
|
||||
{isPageVisibilitySet && (
|
||||
<Button onClick={handleResetToDefault} loading={isUpdating} disabled={isUpdating}>
|
||||
Reset to Default (All Pages)
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
|||
import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
import { Alert, Card, Skeleton, Space, Switch, Typography } from "antd";
|
||||
import PageVisibilitySettings from "./PageVisibilitySettings";
|
||||
import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd";
|
||||
|
||||
export default function UISettings() {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
|
@ -14,6 +15,7 @@ export default function UISettings() {
|
|||
const schema = data?.field_schema;
|
||||
const property = schema?.properties?.disable_model_add_for_internal_users;
|
||||
const disableTeamAdminDeleteProperty = schema?.properties?.disable_team_admin_delete_team_user;
|
||||
const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users;
|
||||
const values = data?.values ?? {};
|
||||
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
|
||||
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
|
||||
|
|
@ -46,6 +48,17 @@ export default function UISettings() {
|
|||
);
|
||||
};
|
||||
|
||||
const handleUpdatePageVisibility = (settings: { enabled_ui_pages_internal_users: string[] | null }) => {
|
||||
updateSettings(settings, {
|
||||
onSuccess: () => {
|
||||
NotificationManager.success("Page visibility settings updated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationManager.fromBackend(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="UI Settings">
|
||||
{isLoading ? (
|
||||
|
|
@ -99,6 +112,16 @@ export default function UISettings() {
|
|||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Page Visibility for Internal Users */}
|
||||
<PageVisibilitySettings
|
||||
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}
|
||||
enabledPagesPropertyDescription={enabledPagesProperty?.description}
|
||||
isUpdating={isUpdating}
|
||||
onUpdate={handleUpdatePageVisibility}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface SidebarProps {
|
|||
setPage: (page: string) => void;
|
||||
defaultSelectedKey: string;
|
||||
collapsed?: boolean;
|
||||
enabledPagesInternalUsers?: string[] | null;
|
||||
}
|
||||
|
||||
// Menu item configuration
|
||||
|
|
@ -59,28 +60,8 @@ interface MenuGroup {
|
|||
roles?: string[];
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapsed = false }) => {
|
||||
const { userId, accessToken, userRole } = useAuthorized();
|
||||
const { data: organizations } = useOrganizations();
|
||||
|
||||
// Check if user is an org_admin
|
||||
const isOrgAdmin = useMemo(() => {
|
||||
if (!userId || !organizations) return false;
|
||||
return organizations.some((org: Organization) =>
|
||||
org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"),
|
||||
);
|
||||
}, [userId, organizations]);
|
||||
|
||||
// Navigate to page helper
|
||||
const navigateToPage = (page: string) => {
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("page", page);
|
||||
window.history.pushState(null, "", `?${newSearchParams.toString()}`);
|
||||
setPage(page);
|
||||
};
|
||||
|
||||
// Menu groups organized by category
|
||||
const menuGroups: MenuGroup[] = [
|
||||
// Menu groups organized by category - defined outside component for export
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
groupLabel: "AI GATEWAY",
|
||||
items: [
|
||||
|
|
@ -152,7 +133,6 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
|
|||
page: "vector-stores",
|
||||
label: "Vector Stores",
|
||||
icon: <DatabaseOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -337,20 +317,82 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
|
|||
},
|
||||
];
|
||||
|
||||
// Filter items based on user role
|
||||
const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapsed = false, enabledPagesInternalUsers }) => {
|
||||
const { userId, accessToken, userRole } = useAuthorized();
|
||||
const { data: organizations } = useOrganizations();
|
||||
|
||||
// Check if user is an org_admin
|
||||
const isOrgAdmin = useMemo(() => {
|
||||
if (!userId || !organizations) return false;
|
||||
return organizations.some((org: Organization) =>
|
||||
org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"),
|
||||
);
|
||||
}, [userId, organizations]);
|
||||
|
||||
// Navigate to page helper
|
||||
const navigateToPage = (page: string) => {
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("page", page);
|
||||
window.history.pushState(null, "", `?${newSearchParams.toString()}`);
|
||||
setPage(page);
|
||||
};
|
||||
|
||||
// Filter items based on user role and enabled pages for internal users
|
||||
const filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
|
||||
const isAdmin = isAdminRole(userRole);
|
||||
|
||||
// Debug logging
|
||||
if (enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
|
||||
console.log("[LeftNav] Filtering with enabled pages:", {
|
||||
userRole,
|
||||
isAdmin,
|
||||
enabledPagesInternalUsers,
|
||||
});
|
||||
}
|
||||
|
||||
return items
|
||||
.filter((item) => {
|
||||
// Special handling for organizations menu item - allow org_admins
|
||||
if (item.key === "organizations") {
|
||||
return !item.roles || item.roles.includes(userRole) || isOrgAdmin;
|
||||
}
|
||||
return !item.roles || item.roles.includes(userRole);
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterItemsByRole(item.children) : undefined,
|
||||
}));
|
||||
}))
|
||||
.filter((item) => {
|
||||
// Special handling for organizations menu item - allow org_admins
|
||||
if (item.key === "organizations") {
|
||||
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
|
||||
if (!hasRoleAccess) return false;
|
||||
|
||||
// Check enabled pages for internal users (non-admins)
|
||||
if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
|
||||
const isIncluded = enabledPagesInternalUsers.includes(item.page);
|
||||
console.log(`[LeftNav] Page "${item.page}" (${item.key}): ${isIncluded ? "VISIBLE" : "HIDDEN"}`);
|
||||
return isIncluded;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Existing role check
|
||||
if (item.roles && !item.roles.includes(userRole)) return false;
|
||||
|
||||
// Check enabled pages for internal users (non-admins)
|
||||
if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
|
||||
// If item has children, check if any children are visible
|
||||
if (item.children && item.children.length > 0) {
|
||||
const hasVisibleChildren = item.children.some((child) =>
|
||||
enabledPagesInternalUsers.includes(child.page)
|
||||
);
|
||||
if (hasVisibleChildren) {
|
||||
console.log(`[LeftNav] Parent "${item.page}" (${item.key}): VISIBLE (has visible children)`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const isIncluded = enabledPagesInternalUsers.includes(item.page);
|
||||
console.log(`[LeftNav] Page "${item.page}" (${item.key}): ${isIncluded ? "VISIBLE" : "HIDDEN"}`);
|
||||
return isIncluded;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Build menu items with groups
|
||||
|
|
@ -485,3 +527,6 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
|
|||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
// Also export menuGroups for advanced use cases
|
||||
export { menuGroups };
|
||||
|
|
|
|||
|
|
@ -5336,6 +5336,66 @@ export const getProxyUISettings = async (accessToken: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const getUISettings = async (accessToken: string) => {
|
||||
/**
|
||||
* Get UI-specific configuration flags from the database
|
||||
*/
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
console.error("Failed to get UI settings:", errorMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to get UI settings:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUISettings = async (accessToken: string, settings: any) => {
|
||||
/**
|
||||
* Update UI-specific configuration flags in the database
|
||||
* Only proxy admins can update these settings
|
||||
*/
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/update/ui_settings` : `/update/ui_settings`;
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to update UI settings:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGuardrailsList = async (accessToken: string) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/v2/guardrails/list` : `/v2/guardrails/list`;
|
||||
|
|
|
|||
44
ui/litellm-dashboard/src/components/page_metadata.ts
Normal file
44
ui/litellm-dashboard/src/components/page_metadata.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Page metadata for UI Settings configuration
|
||||
* This file contains descriptions and metadata for all navigation pages
|
||||
*/
|
||||
|
||||
// Page descriptions for UI Settings configuration
|
||||
export const pageDescriptions: Record<string, string> = {
|
||||
"api-keys": "Manage virtual keys for API access and authentication",
|
||||
"llm-playground": "Interactive playground for testing LLM requests",
|
||||
models: "Configure and manage LLM models and endpoints",
|
||||
agents: "Create and manage AI agents",
|
||||
"mcp-servers": "Configure Model Context Protocol servers",
|
||||
guardrails: "Set up content moderation and safety guardrails",
|
||||
policies: "Define access control and usage policies",
|
||||
"search-tools": "Configure RAG search and retrieval tools",
|
||||
"vector-stores": "Manage vector databases for embeddings",
|
||||
new_usage: "View usage analytics and metrics",
|
||||
logs: "Access request and response logs",
|
||||
users: "Manage internal user accounts and permissions",
|
||||
teams: "Create and manage teams for access control",
|
||||
organizations: "Manage organizations and their members",
|
||||
budgets: "Set and monitor spending budgets",
|
||||
api_ref: "Browse API documentation and endpoints",
|
||||
"model-hub-table": "Explore available AI models and providers",
|
||||
"learning-resources": "Access tutorials and documentation",
|
||||
caching: "Configure response caching settings",
|
||||
"transform-request": "Set up request transformation rules",
|
||||
"cost-tracking": "Track and analyze API costs",
|
||||
"ui-theme": "Customize dashboard appearance",
|
||||
"tag-management": "Organize resources with tags",
|
||||
prompts: "Manage and version prompt templates",
|
||||
"claude-code-plugins": "Configure Claude Code plugins",
|
||||
usage: "View legacy usage dashboard",
|
||||
"router-settings": "Configure routing and load balancing settings",
|
||||
"logging-and-alerts": "Set up logging and alert configurations",
|
||||
"admin-panel": "Access admin panel and settings",
|
||||
};
|
||||
|
||||
export interface PageMetadata {
|
||||
page: string;
|
||||
label: string;
|
||||
group: string;
|
||||
description: string;
|
||||
}
|
||||
219
ui/litellm-dashboard/src/components/page_utils.test.ts
Normal file
219
ui/litellm-dashboard/src/components/page_utils.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Tests to ensure page metadata stays in sync with leftnav configuration
|
||||
* This catches issues when leftnav structure changes but page_utils/page_metadata aren't updated
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAvailablePages } from "./page_utils";
|
||||
import { menuGroups } from "./leftnav";
|
||||
import { pageDescriptions } from "./page_metadata";
|
||||
|
||||
describe("Page Utils - LeftNav Sync", () => {
|
||||
it("should return all pages from leftnav configuration", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
|
||||
// Should have pages
|
||||
expect(availablePages.length).toBeGreaterThan(0);
|
||||
|
||||
// Each page should have required fields
|
||||
availablePages.forEach((page) => {
|
||||
expect(page).toHaveProperty("page");
|
||||
expect(page).toHaveProperty("label");
|
||||
expect(page).toHaveProperty("group");
|
||||
expect(page).toHaveProperty("description");
|
||||
expect(typeof page.page).toBe("string");
|
||||
expect(typeof page.label).toBe("string");
|
||||
expect(typeof page.group).toBe("string");
|
||||
expect(typeof page.description).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("should include all navigable pages from menuGroups", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePageKeys = availablePages.map((p) => p.page);
|
||||
|
||||
// Collect all page keys from menuGroups (excluding parent containers)
|
||||
const menuPageKeys: string[] = [];
|
||||
const excludedParents = ["tools", "experimental", "settings"];
|
||||
|
||||
menuGroups.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (item.page && !excludedParents.includes(item.page)) {
|
||||
menuPageKeys.push(item.page);
|
||||
}
|
||||
|
||||
// Add children
|
||||
if (item.children) {
|
||||
item.children.forEach((child) => {
|
||||
menuPageKeys.push(child.page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Every menu page should be in available pages
|
||||
menuPageKeys.forEach((pageKey) => {
|
||||
expect(
|
||||
availablePageKeys,
|
||||
`Page "${pageKey}" from menuGroups should be in getAvailablePages() output`
|
||||
).toContain(pageKey);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include parent container pages (tools, experimental, settings)", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePageKeys = availablePages.map((p) => p.page);
|
||||
|
||||
const excludedParents = ["tools", "experimental", "settings"];
|
||||
|
||||
excludedParents.forEach((parent) => {
|
||||
expect(
|
||||
availablePageKeys,
|
||||
`Parent container "${parent}" should not be in available pages`
|
||||
).not.toContain(parent);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have descriptions for all pages", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
|
||||
availablePages.forEach((page) => {
|
||||
expect(
|
||||
page.description,
|
||||
`Page "${page.page}" should have a description`
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
page.description,
|
||||
`Page "${page.page}" should not have placeholder description`
|
||||
).not.toBe("No description available");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have pageDescriptions entry for all navigable pages in menuGroups", () => {
|
||||
// Collect all page keys from menuGroups
|
||||
const menuPageKeys: string[] = [];
|
||||
const excludedParents = ["tools", "experimental", "settings"];
|
||||
|
||||
menuGroups.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (item.page && !excludedParents.includes(item.page)) {
|
||||
menuPageKeys.push(item.page);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
item.children.forEach((child) => {
|
||||
menuPageKeys.push(child.page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Every menu page should have a description
|
||||
const missingDescriptions: string[] = [];
|
||||
menuPageKeys.forEach((pageKey) => {
|
||||
if (!pageDescriptions[pageKey]) {
|
||||
missingDescriptions.push(pageKey);
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
missingDescriptions,
|
||||
`These pages are missing descriptions in page_metadata.ts: ${missingDescriptions.join(", ")}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not have orphaned descriptions (descriptions for pages not in menuGroups)", () => {
|
||||
// Collect all page keys from menuGroups
|
||||
const menuPageKeys: string[] = [];
|
||||
const excludedParents = ["tools", "experimental", "settings"];
|
||||
|
||||
menuGroups.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (item.page && !excludedParents.includes(item.page)) {
|
||||
menuPageKeys.push(item.page);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
item.children.forEach((child) => {
|
||||
menuPageKeys.push(child.page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Check for descriptions that don't match any menu page
|
||||
const orphanedDescriptions: string[] = [];
|
||||
Object.keys(pageDescriptions).forEach((descKey) => {
|
||||
if (!menuPageKeys.includes(descKey)) {
|
||||
orphanedDescriptions.push(descKey);
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
orphanedDescriptions,
|
||||
`These descriptions don't match any page in menuGroups: ${orphanedDescriptions.join(", ")}. Remove them or add the pages to leftnav.`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should have proper group hierarchy for nested pages", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
|
||||
// Find pages that should be nested (children of Tools, Experimental, Settings)
|
||||
const nestedPages = availablePages.filter((page) =>
|
||||
page.group.includes(" > ")
|
||||
);
|
||||
|
||||
// Each nested page should have parent > child format
|
||||
nestedPages.forEach((page) => {
|
||||
const parts = page.group.split(" > ");
|
||||
expect(
|
||||
parts.length,
|
||||
`Nested page "${page.page}" should have exactly 2 parts in group hierarchy`
|
||||
).toBe(2);
|
||||
|
||||
// Parent should be one of the group labels
|
||||
const parentGroup = parts[0];
|
||||
const groupLabels = menuGroups.map((g) => g.groupLabel);
|
||||
expect(
|
||||
groupLabels,
|
||||
`Parent group "${parentGroup}" for page "${page.page}" should be a valid group label`
|
||||
).toContain(parentGroup);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have unique page keys", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const pageKeys = availablePages.map((p) => p.page);
|
||||
const uniquePageKeys = new Set(pageKeys);
|
||||
|
||||
expect(
|
||||
pageKeys.length,
|
||||
"All page keys should be unique (no duplicates)"
|
||||
).toBe(uniquePageKeys.size);
|
||||
});
|
||||
|
||||
it("should match the structure expected by PageVisibilitySettings component", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
|
||||
// Group pages by their group (same logic as in PageVisibilitySettings)
|
||||
const grouped: Record<string, typeof availablePages> = {};
|
||||
availablePages.forEach((page) => {
|
||||
if (!grouped[page.group]) {
|
||||
grouped[page.group] = [];
|
||||
}
|
||||
grouped[page.group].push(page);
|
||||
});
|
||||
|
||||
// Should have multiple groups
|
||||
expect(Object.keys(grouped).length).toBeGreaterThan(1);
|
||||
|
||||
// Each group should have at least one page
|
||||
Object.entries(grouped).forEach(([groupName, pages]) => {
|
||||
expect(
|
||||
pages.length,
|
||||
`Group "${groupName}" should have at least one page`
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
75
ui/litellm-dashboard/src/components/page_utils.ts
Normal file
75
ui/litellm-dashboard/src/components/page_utils.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Utility functions for working with navigation pages
|
||||
*/
|
||||
|
||||
import { menuGroups } from "./leftnav";
|
||||
import { pageDescriptions, PageMetadata } from "./page_metadata";
|
||||
import { internalUserRoles } from "@/utils/roles";
|
||||
|
||||
/**
|
||||
* Check if a page is accessible to internal users
|
||||
* A page is accessible if:
|
||||
* 1. It has no role restrictions, OR
|
||||
* 2. Its roles include at least one internal user role
|
||||
*/
|
||||
const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => {
|
||||
if (!pageRoles || pageRoles.length === 0) {
|
||||
return true; // No role restrictions
|
||||
}
|
||||
|
||||
// Check if any of the page's roles match internal user roles
|
||||
return pageRoles.some(role => internalUserRoles.includes(role));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available pages from the navigation menu configuration
|
||||
* Used by UI Settings to display available pages for visibility control
|
||||
*
|
||||
* IMPORTANT: Only returns pages that internal users can access.
|
||||
* Pages restricted to admin-only roles are excluded because internal users
|
||||
* cannot see them regardless of the UI visibility setting.
|
||||
*/
|
||||
export const getAvailablePages = (): PageMetadata[] => {
|
||||
const pages: PageMetadata[] = [];
|
||||
|
||||
menuGroups.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
// Add top-level items (skip parent containers like 'tools', 'experimental', 'settings')
|
||||
// Also skip items that internal users cannot access
|
||||
if (
|
||||
item.page &&
|
||||
item.page !== "tools" &&
|
||||
item.page !== "experimental" &&
|
||||
item.page !== "settings" &&
|
||||
isPageAccessibleToInternalUsers(item.roles)
|
||||
) {
|
||||
const label = typeof item.label === "string" ? item.label : item.key;
|
||||
pages.push({
|
||||
page: item.page,
|
||||
label: label,
|
||||
group: group.groupLabel,
|
||||
description: pageDescriptions[item.page] || "No description available",
|
||||
});
|
||||
}
|
||||
|
||||
// Add children items (also skip those internal users cannot access)
|
||||
if (item.children) {
|
||||
const parentLabel = typeof item.label === "string" ? item.label : item.key;
|
||||
item.children.forEach((child) => {
|
||||
// Include if internal users can access
|
||||
if (isPageAccessibleToInternalUsers(child.roles)) {
|
||||
const childLabel = typeof child.label === "string" ? child.label : child.key;
|
||||
pages.push({
|
||||
page: child.page,
|
||||
label: childLabel,
|
||||
group: `${group.groupLabel} > ${parentLabel}`,
|
||||
description: pageDescriptions[child.page] || "No description available",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return pages;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue