mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(ui): control which usage views internal users can see
This commit is contained in:
parent
cd6e8cdf23
commit
6c06beea4b
10 changed files with 504 additions and 24 deletions
|
|
@ -110,6 +110,11 @@ class UISettings(BaseModel):
|
|||
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.",
|
||||
)
|
||||
|
||||
enabled_usage_views_internal_users: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="List of Usage page view keys that internal users (non-admins) can select in the Usage view dropdown. If not set, all non-admin views are visible. Admins always see every view.",
|
||||
)
|
||||
|
||||
require_auth_for_public_ai_hub: bool = Field(
|
||||
default=False,
|
||||
description="If true, requires authentication for accessing the public AI Hub.",
|
||||
|
|
@ -194,6 +199,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
|
|||
"disable_model_add_for_internal_users",
|
||||
"disable_team_admin_delete_team_user",
|
||||
"enabled_ui_pages_internal_users",
|
||||
"enabled_usage_views_internal_users",
|
||||
"require_auth_for_public_ai_hub",
|
||||
"allow_public_health_readiness_details",
|
||||
"forward_client_headers_to_llm_api",
|
||||
|
|
|
|||
|
|
@ -1093,6 +1093,85 @@ class TestProxySettingEndpoints:
|
|||
stored_settings = json.loads(create_data["ui_settings"])
|
||||
assert stored_settings["disable_model_add_for_internal_users"] is True
|
||||
|
||||
def test_update_ui_settings_persists_enabled_usage_views_internal_users(
|
||||
self, mock_auth, monkeypatch
|
||||
):
|
||||
"""enabled_usage_views_internal_users must be allowlisted and persisted as a list."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
user_id="test-user-123",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
|
||||
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
payload = {"enabled_usage_views_internal_users": ["global"]}
|
||||
|
||||
try:
|
||||
response = client.patch("/update/ui_settings", json=payload)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert data["settings"]["enabled_usage_views_internal_users"] == ["global"]
|
||||
|
||||
call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
|
||||
stored_settings = json.loads(call_args.kwargs["data"]["create"]["ui_settings"])
|
||||
assert stored_settings["enabled_usage_views_internal_users"] == ["global"]
|
||||
|
||||
def test_update_ui_settings_resets_enabled_usage_views_to_null(
|
||||
self, mock_auth, monkeypatch
|
||||
):
|
||||
"""Sending null for enabled_usage_views_internal_users restores default behaviour."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
user_id="test-user-123",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
|
||||
mock_existing = MagicMock()
|
||||
mock_existing.ui_settings = json.dumps(
|
||||
{"enabled_usage_views_internal_users": ["global"]}
|
||||
)
|
||||
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(
|
||||
return_value=mock_existing
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
payload = {"enabled_usage_views_internal_users": None}
|
||||
|
||||
try:
|
||||
response = client.patch("/update/ui_settings", json=payload)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["settings"]["enabled_usage_views_internal_users"] is None
|
||||
|
||||
call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
|
||||
stored_settings = json.loads(call_args.kwargs["data"]["update"]["ui_settings"])
|
||||
assert stored_settings["enabled_usage_views_internal_users"] is None
|
||||
|
||||
def test_update_ui_settings_ignores_non_allowlisted_value(
|
||||
self, mock_auth, monkeypatch
|
||||
):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdat
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
import PageVisibilitySettings from "./PageVisibilitySettings";
|
||||
import UsageViewVisibilitySettings from "./UsageViewVisibilitySettings";
|
||||
import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd";
|
||||
|
||||
export default function UISettings() {
|
||||
|
|
@ -21,6 +22,7 @@ export default function UISettings() {
|
|||
const enableProjectsUIProperty = schema?.properties?.enable_projects_ui;
|
||||
const enableChatUIProperty = schema?.properties?.enable_chat_ui;
|
||||
const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users;
|
||||
const enabledUsageViewsProperty = schema?.properties?.enabled_usage_views_internal_users;
|
||||
const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users;
|
||||
const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins;
|
||||
const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users;
|
||||
|
|
@ -72,6 +74,17 @@ export default function UISettings() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleUpdateUsageViewVisibility = (settings: { enabled_usage_views_internal_users: string[] | null }) => {
|
||||
updateSettings(settings, {
|
||||
onSuccess: () => {
|
||||
NotificationManager.success("Usage view visibility settings updated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationManager.fromBackend(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleForwardClientHeaders = (checked: boolean) => {
|
||||
updateSettings(
|
||||
{ forward_client_headers_to_llm_api: checked },
|
||||
|
|
@ -491,6 +504,15 @@ export default function UISettings() {
|
|||
isUpdating={isUpdating}
|
||||
onUpdate={handleUpdatePageVisibility}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageViewVisibilitySettings
|
||||
enabledViewsInternalUsers={values.enabled_usage_views_internal_users}
|
||||
enabledViewsPropertyDescription={enabledUsageViewsProperty?.description}
|
||||
isUpdating={isUpdating}
|
||||
onUpdate={handleUpdateUsageViewVisibility}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import UsageViewVisibilitySettings from "./UsageViewVisibilitySettings";
|
||||
|
||||
vi.mock("@/components/UsagePage/components/UsageViewSelect/UsageViewSelect", () => ({
|
||||
getConfigurableNonAdminUsageViews: () => [
|
||||
{ value: "global", label: "Your Usage", description: "View your usage" },
|
||||
{ value: "organization", label: "Your Organization Usage", description: "View your organization's usage" },
|
||||
{ value: "team", label: "Team Usage", description: "View usage by team" },
|
||||
{ value: "tag", label: "Tag Usage", description: "View usage grouped by tags" },
|
||||
],
|
||||
}));
|
||||
|
||||
describe("UsageViewVisibilitySettings", () => {
|
||||
it("should render the not-set tag when enabledViewsInternalUsers is null", () => {
|
||||
render(<UsageViewVisibilitySettings enabledViewsInternalUsers={null} isUpdating={false} onUpdate={vi.fn()} />);
|
||||
expect(screen.getByText("Not set (all views visible)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the selected view count tag when views are configured", () => {
|
||||
render(
|
||||
<UsageViewVisibilitySettings
|
||||
enabledViewsInternalUsers={["global", "team"]}
|
||||
isUpdating={false}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("2 views selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show singular 'view' when exactly one view is selected", () => {
|
||||
render(
|
||||
<UsageViewVisibilitySettings enabledViewsInternalUsers={["global"]} isUpdating={false} onUpdate={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText("1 view selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should save only the selected views", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<UsageViewVisibilitySettings enabledViewsInternalUsers={["global"]} isUpdating={false} onUpdate={onUpdate} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /configure usage view visibility/i }));
|
||||
await user.click(await screen.findByRole("button", { name: /save usage view visibility settings/i }));
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: ["global"] });
|
||||
});
|
||||
|
||||
it("should save null when no views are selected", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<UsageViewVisibilitySettings enabledViewsInternalUsers={["global"]} isUpdating={false} onUpdate={onUpdate} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /configure usage view visibility/i }));
|
||||
await user.click(await screen.findByRole("checkbox", { name: /your usage/i }));
|
||||
await user.click(screen.getByRole("button", { name: /save usage view visibility settings/i }));
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: null });
|
||||
});
|
||||
|
||||
it("should call onUpdate with null when reset button is clicked", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<UsageViewVisibilitySettings enabledViewsInternalUsers={["team"]} isUpdating={false} onUpdate={onUpdate} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /configure usage view visibility/i }));
|
||||
await user.click(await screen.findByRole("button", { name: /reset to default/i }));
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: null });
|
||||
});
|
||||
|
||||
it("should display the property description when provided", () => {
|
||||
render(
|
||||
<UsageViewVisibilitySettings
|
||||
enabledViewsInternalUsers={null}
|
||||
enabledViewsPropertyDescription="Controls which usage views are visible"
|
||||
isUpdating={false}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Controls which usage views are visible")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import { getConfigurableNonAdminUsageViews } from "@/components/UsagePage/components/UsageViewSelect/UsageViewSelect";
|
||||
import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface UsageViewVisibilitySettingsProps {
|
||||
enabledViewsInternalUsers: string[] | null | undefined;
|
||||
enabledViewsPropertyDescription?: string;
|
||||
isUpdating: boolean;
|
||||
onUpdate: (settings: { enabled_usage_views_internal_users: string[] | null }) => void;
|
||||
}
|
||||
|
||||
export default function UsageViewVisibilitySettings({
|
||||
enabledViewsInternalUsers,
|
||||
enabledViewsPropertyDescription,
|
||||
isUpdating,
|
||||
onUpdate,
|
||||
}: UsageViewVisibilitySettingsProps) {
|
||||
const isVisibilitySet = enabledViewsInternalUsers !== null && enabledViewsInternalUsers !== undefined;
|
||||
|
||||
const availableViews = useMemo(() => getConfigurableNonAdminUsageViews(), []);
|
||||
|
||||
const [selectedViews, setSelectedViews] = useState<string[]>(enabledViewsInternalUsers ?? []);
|
||||
const [syncedFrom, setSyncedFrom] = useState(enabledViewsInternalUsers);
|
||||
if (syncedFrom !== enabledViewsInternalUsers) {
|
||||
setSyncedFrom(enabledViewsInternalUsers);
|
||||
setSelectedViews(enabledViewsInternalUsers ?? []);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({ enabled_usage_views_internal_users: selectedViews.length > 0 ? selectedViews : null });
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
setSelectedViews([]);
|
||||
onUpdate({ enabled_usage_views_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 Usage View Visibility</Typography.Text>
|
||||
{!isVisibilitySet && (
|
||||
<Tag color="default" style={{ marginLeft: "8px" }}>
|
||||
Not set (all views visible)
|
||||
</Tag>
|
||||
)}
|
||||
{isVisibilitySet && (
|
||||
<Tag color="blue" style={{ marginLeft: "8px" }}>
|
||||
{selectedViews.length} view{selectedViews.length !== 1 ? "s" : ""} selected
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{enabledViewsPropertyDescription && (
|
||||
<Typography.Text type="secondary">{enabledViewsPropertyDescription}</Typography.Text>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: "12px", fontStyle: "italic" }}>
|
||||
By default, all non-admin views are visible to internal users in the Usage page dropdown. Select specific
|
||||
views to restrict which options they can choose.
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: "12px", color: "#8b5cf6" }}>
|
||||
Note: Admins always see every usage view regardless of this setting.
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: "usage-view-visibility",
|
||||
label: "Configure Usage View Visibility",
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Checkbox.Group value={selectedViews} onChange={setSelectedViews} style={{ width: "100%" }}>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
{availableViews.map((view) => (
|
||||
<div key={view.value} style={{ marginBottom: "4px" }}>
|
||||
<Checkbox value={view.value}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{view.label}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{view.description}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSave} loading={isUpdating} disabled={isUpdating}>
|
||||
Save Usage View Visibility Settings
|
||||
</Button>
|
||||
{isVisibilitySet && (
|
||||
<Button onClick={handleResetToDefault} loading={isUpdating} disabled={isUpdating}>
|
||||
Reset to Default (All Views)
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,7 +77,26 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
|
|||
);
|
||||
};
|
||||
UsageViewSelect.displayName = "UsageViewSelect";
|
||||
return { UsageViewSelect };
|
||||
const getVisibleUsageOptions = ({
|
||||
isAdmin,
|
||||
canViewTagUsage = false,
|
||||
enabledViews = null,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
canViewTagUsage?: boolean;
|
||||
enabledViews?: string[] | null;
|
||||
}) => {
|
||||
const base = isAdmin
|
||||
? ["global", "my-usage", "organization", "team", "customer", "tag", "agent", "user", "user-agent-activity"]
|
||||
: ["global", "organization", "team", ...(canViewTagUsage ? ["tag"] : [])];
|
||||
if (!isAdmin && enabledViews != null) {
|
||||
return base.filter((v) => enabledViews.includes(v));
|
||||
}
|
||||
return base;
|
||||
};
|
||||
const resolveActiveUsageView = (current: string, visibleViews: string[]) =>
|
||||
visibleViews.length === 0 || visibleViews.includes(current) ? current : visibleViews[0];
|
||||
return { UsageViewSelect, getVisibleUsageOptions, resolveActiveUsageView };
|
||||
});
|
||||
|
||||
vi.mock("../../shared/advanced_date_picker", async () => {
|
||||
|
|
@ -126,6 +145,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
|||
useInfiniteUsers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: vi.fn(() => ({ data: { values: {}, field_schema: {} } })),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const actual = await importOriginal<typeof import("antd")>();
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
|||
import SpendByProvider from "./EntityUsage/SpendByProvider";
|
||||
import TopKeyView from "./EntityUsage/TopKeyView";
|
||||
import UsageAIChatPanel from "./UsageAIChatPanel";
|
||||
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import {
|
||||
getVisibleUsageOptions,
|
||||
resolveActiveUsageView,
|
||||
UsageOption,
|
||||
UsageViewSelect,
|
||||
} from "./UsageViewSelect/UsageViewSelect";
|
||||
|
||||
interface UsagePageProps {
|
||||
teams: Team[];
|
||||
|
|
@ -83,6 +89,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const { data: currentUser } = useCurrentUser();
|
||||
const isAdmin = all_admin_roles.includes(userRole || "");
|
||||
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
|
||||
const { data: uiSettings } = useUISettings();
|
||||
const enabledUsageViews = (uiSettings?.values?.enabled_usage_views_internal_users ?? null) as UsageOption[] | null;
|
||||
const visibleUsageOptions = useMemo(
|
||||
() => getVisibleUsageOptions({ isAdmin, canViewTagUsage, enabledViews: enabledUsageViews }),
|
||||
[isAdmin, canViewTagUsage, enabledUsageViews],
|
||||
);
|
||||
|
||||
// Debounced search for user selector
|
||||
const [userSearchInput, setUserSearchInput] = useState("");
|
||||
|
|
@ -151,8 +163,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
}
|
||||
}, [isAdmin, userID]);
|
||||
|
||||
const effectiveUsageView = resolveActiveUsageView(usageView, visibleUsageOptions);
|
||||
|
||||
// For non-admins or "my-usage" view, always pass their own user_id
|
||||
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
|
||||
const effectiveUserId = effectiveUsageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
|
||||
|
||||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
|
@ -444,10 +458,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<div className="flex-1">
|
||||
<div className="flex items-end justify-between gap-6 mb-4 w-full">
|
||||
<UsageViewSelect
|
||||
value={usageView}
|
||||
value={effectiveUsageView}
|
||||
onChange={(value) => setUsageView(value)}
|
||||
isAdmin={isAdmin}
|
||||
canViewTagUsage={canViewTagUsage}
|
||||
enabledViews={enabledUsageViews}
|
||||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
|
|
@ -489,9 +504,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
{/* Your Usage / Global Usage Panel */}
|
||||
{(usageView === "global" || usageView === "my-usage") && (
|
||||
{(effectiveUsageView === "global" || effectiveUsageView === "my-usage") && (
|
||||
<>
|
||||
{isAdmin && usageView === "global" && (
|
||||
{isAdmin && effectiveUsageView === "global" && (
|
||||
<div className="mb-4">
|
||||
<Text className="mb-2">Filter by user</Text>
|
||||
<Select
|
||||
|
|
@ -844,7 +859,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
)}
|
||||
{/* Organization Usage Panel */}
|
||||
|
||||
{usageView === "organization" && (
|
||||
{effectiveUsageView === "organization" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="organization"
|
||||
|
|
@ -862,7 +877,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
)}
|
||||
|
||||
{/* Team Usage Panel */}
|
||||
{usageView === "team" && (
|
||||
{effectiveUsageView === "team" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="team"
|
||||
|
|
@ -880,7 +895,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
)}
|
||||
|
||||
{/* Customer Usage Panel */}
|
||||
{usageView === "customer" && (
|
||||
{effectiveUsageView === "customer" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="customer"
|
||||
|
|
@ -897,7 +912,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
{/* Tag Usage Panel */}
|
||||
{usageView === "tag" && (
|
||||
{effectiveUsageView === "tag" && (
|
||||
<>
|
||||
{showCredentialBanner && (
|
||||
<Alert
|
||||
|
|
@ -927,7 +942,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
{usageView === "agent" && (
|
||||
{effectiveUsageView === "agent" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="agent"
|
||||
|
|
@ -941,7 +956,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
{/* User Usage Panel */}
|
||||
{usageView === "user" && (
|
||||
{effectiveUsageView === "user" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="user"
|
||||
|
|
@ -953,7 +968,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
{/* User Agent Activity Panel */}
|
||||
{usageView === "user-agent-activity" && (
|
||||
{effectiveUsageView === "user-agent-activity" && (
|
||||
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UsageViewSelect } from "./UsageViewSelect";
|
||||
import {
|
||||
getConfigurableNonAdminUsageViews,
|
||||
getVisibleUsageOptions,
|
||||
resolveActiveUsageView,
|
||||
UsageViewSelect,
|
||||
} from "./UsageViewSelect";
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
|
|
@ -122,4 +127,102 @@ describe("UsageViewSelect", () => {
|
|||
|
||||
expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should restrict non-admin options to enabledViews when set", () => {
|
||||
render(
|
||||
<UsageViewSelect
|
||||
value="global"
|
||||
onChange={mockOnChange}
|
||||
isAdmin={false}
|
||||
canViewTagUsage={true}
|
||||
enabledViews={["global"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Your Organization Usage" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Team Usage" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should ignore enabledViews for admins", () => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} isAdmin={true} enabledViews={["global"]} />);
|
||||
|
||||
expect(screen.getByRole("option", { name: "Team Usage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Organization Usage" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all non-admin options when enabledViews is null", () => {
|
||||
render(
|
||||
<UsageViewSelect
|
||||
value="global"
|
||||
onChange={mockOnChange}
|
||||
isAdmin={false}
|
||||
canViewTagUsage={true}
|
||||
enabledViews={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Your Organization Usage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Team Usage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleUsageOptions", () => {
|
||||
it("should return only role-visible non-admin views by default", () => {
|
||||
expect(getVisibleUsageOptions({ isAdmin: false, canViewTagUsage: true })).toEqual([
|
||||
"global",
|
||||
"organization",
|
||||
"team",
|
||||
"tag",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should exclude tag when canViewTagUsage is false", () => {
|
||||
expect(getVisibleUsageOptions({ isAdmin: false, canViewTagUsage: false })).toEqual([
|
||||
"global",
|
||||
"organization",
|
||||
"team",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should intersect with enabledViews for non-admins", () => {
|
||||
expect(getVisibleUsageOptions({ isAdmin: false, canViewTagUsage: true, enabledViews: ["global", "team"] })).toEqual(
|
||||
["global", "team"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore enabledViews for admins", () => {
|
||||
const adminViews = getVisibleUsageOptions({ isAdmin: true, enabledViews: ["global"] });
|
||||
expect(adminViews).toContain("team");
|
||||
expect(adminViews).toContain("tag");
|
||||
expect(adminViews).toContain("customer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfigurableNonAdminUsageViews", () => {
|
||||
it("should list exactly the views configurable for non-admins with their non-admin labels", () => {
|
||||
expect(getConfigurableNonAdminUsageViews()).toEqual([
|
||||
{ value: "global", label: "Your Usage", description: "View your usage" },
|
||||
{ value: "organization", label: "Your Organization Usage", description: "View your organization's usage" },
|
||||
{ value: "team", label: "Team Usage", description: "View usage by team" },
|
||||
{ value: "tag", label: "Tag Usage", description: "View usage grouped by tags" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActiveUsageView", () => {
|
||||
it("should keep the current view when it is still visible", () => {
|
||||
expect(resolveActiveUsageView("team", ["global", "team", "tag"])).toBe("team");
|
||||
});
|
||||
|
||||
it("should fall back to the first visible view when the current one is hidden", () => {
|
||||
expect(resolveActiveUsageView("team", ["global", "tag"])).toBe("global");
|
||||
});
|
||||
|
||||
it("should keep the current view when there are no visible views to fall back to", () => {
|
||||
expect(resolveActiveUsageView("team", [])).toBe("team");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface UsageViewSelectProps {
|
|||
onChange: (value: UsageOption) => void;
|
||||
isAdmin: boolean;
|
||||
canViewTagUsage?: boolean;
|
||||
enabledViews?: UsageOption[] | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
"data-id"?: string;
|
||||
|
|
@ -112,25 +113,59 @@ const OPTIONS: OptionConfig[] = [
|
|||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
export interface UsageViewVisibilityContext {
|
||||
isAdmin: boolean;
|
||||
canViewTagUsage?: boolean;
|
||||
enabledViews?: UsageOption[] | null;
|
||||
}
|
||||
|
||||
const isVisibleByRole = (
|
||||
option: OptionConfig,
|
||||
{ isAdmin, canViewTagUsage = false }: UsageViewVisibilityContext,
|
||||
): boolean => {
|
||||
if (option.value === "tag" && canViewTagUsage) {
|
||||
return true;
|
||||
}
|
||||
if (option.adminOnly && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getVisibleUsageOptions = (context: UsageViewVisibilityContext): UsageOption[] =>
|
||||
OPTIONS.filter((option) => {
|
||||
if (!isVisibleByRole(option, context)) {
|
||||
return false;
|
||||
}
|
||||
if (!context.isAdmin && context.enabledViews != null && !context.enabledViews.includes(option.value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((option) => option.value);
|
||||
|
||||
export const resolveActiveUsageView = (current: UsageOption, visibleViews: UsageOption[]): UsageOption =>
|
||||
visibleViews.length === 0 || visibleViews.includes(current) ? current : visibleViews[0];
|
||||
|
||||
export const getConfigurableNonAdminUsageViews = (): { value: UsageOption; label: string; description: string }[] =>
|
||||
OPTIONS.filter((option) => isVisibleByRole(option, { isAdmin: false, canViewTagUsage: true })).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.showForNonAdmin ?? option.label,
|
||||
description: option.descriptionForNonAdmin ?? option.description,
|
||||
}));
|
||||
|
||||
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
isAdmin,
|
||||
canViewTagUsage = false,
|
||||
enabledViews = null,
|
||||
title = "Usage View",
|
||||
description = "Select the usage data you want to view",
|
||||
"data-id": dataId,
|
||||
}) => {
|
||||
const getFilteredOptions = () => {
|
||||
return OPTIONS.filter((option) => {
|
||||
if (option.value === "tag" && canViewTagUsage) {
|
||||
return true;
|
||||
}
|
||||
if (option.adminOnly && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((option) => {
|
||||
const visibleValues = new Set(getVisibleUsageOptions({ isAdmin, canViewTagUsage, enabledViews }));
|
||||
return OPTIONS.filter((option) => visibleValues.has(option.value)).map((option) => {
|
||||
let label = option.label;
|
||||
let desc = option.description;
|
||||
if (option.showForAdmin && option.showForNonAdmin) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue