Merge pull request #15196 from BerriAI/litellm_staging_10_04_2025

Litellm staging 10 04 2025
This commit is contained in:
Krish Dholakia
2025-10-04 14:19:45 -07:00
committed by GitHub
16 changed files with 851 additions and 103 deletions
@@ -43,7 +43,7 @@ def _set_object_metadata_field(
value: Value to set for the field
"""
if field_name in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
_premium_user_check()
_premium_user_check(field_name)
object_data.metadata = object_data.metadata or {}
object_data.metadata[field_name] = value
@@ -903,7 +903,7 @@ def prepare_metadata_fields(
if k in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
from litellm.proxy.utils import _premium_user_check
_premium_user_check()
_premium_user_check(k)
casted_metadata[k] = v
except Exception as e:
+7 -2
View File
@@ -3575,17 +3575,22 @@ def handle_exception_on_proxy(e: Exception) -> ProxyException:
)
def _premium_user_check():
def _premium_user_check(feature:str=None):
"""
Raises an HTTPException if the user is not a premium user
"""
from litellm.proxy.proxy_server import premium_user
if feature:
detail_msg = f"This feature is only available for LiteLLM Enterprise users: {feature}. {CommonProxyErrors.not_premium_user.value}"
else:
detail_msg = f"This feature is only available for LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}"
if not premium_user:
raise HTTPException(
status_code=403,
detail={
"error": f"This feature is only available for LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}"
"error": detail_msg
},
)
+1 -1
View File
@@ -22,7 +22,7 @@ import SpendLogsTable from "@/components/view_logs"
import ModelHubTable from "@/components/model_hub_table"
import NewUsagePage from "@/components/new_usage"
import APIRef from "@/components/api_ref"
import ChatUI from "@/components/chat_ui"
import ChatUI from "@/components/chat_ui/ChatUI"
import Sidebar from "@/components/leftnav"
import Usage from "@/components/usage"
import CacheDashboard from "@/components/cache_dashboard"
@@ -25,34 +25,34 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { message, Select, Spin, Typography, Tooltip, Input, Upload, Modal, Button } from "antd";
import { makeOpenAIChatCompletionRequest } from "./chat_ui/llm_calls/chat_completion";
import { makeOpenAIImageGenerationRequest } from "./chat_ui/llm_calls/image_generation";
import { makeOpenAIImageEditsRequest } from "./chat_ui/llm_calls/image_edits";
import { makeOpenAIResponsesRequest } from "./chat_ui/llm_calls/responses_api";
import { makeAnthropicMessagesRequest } from "./chat_ui/llm_calls/anthropic_messages";
import { fetchAvailableModels, ModelGroup } from "./chat_ui/llm_calls/fetch_models";
import { fetchAvailableMCPTools } from "./chat_ui/llm_calls/fetch_mcp_tools";
import type { MCPTool } from "./chat_ui/llm_calls/fetch_mcp_tools";
import { litellmModeMapping, ModelMode, EndpointType, getEndpointType } from "./chat_ui/mode_endpoint_mapping";
import { makeOpenAIChatCompletionRequest } from "./llm_calls/chat_completion";
import { makeOpenAIImageGenerationRequest } from "./llm_calls/image_generation";
import { makeOpenAIImageEditsRequest } from "./llm_calls/image_edits";
import { makeOpenAIResponsesRequest } from "./llm_calls/responses_api";
import { makeAnthropicMessagesRequest } from "./llm_calls/anthropic_messages";
import { fetchAvailableModels, ModelGroup } from "./llm_calls/fetch_models";
import { fetchAvailableMCPTools } from "./llm_calls/fetch_mcp_tools";
import type { MCPTool } from "./llm_calls/fetch_mcp_tools";
import { litellmModeMapping, ModelMode, EndpointType, getEndpointType } from "./mode_endpoint_mapping";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism';
import EndpointSelector from "./chat_ui/EndpointSelector";
import TagSelector from "./tag_management/TagSelector";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import GuardrailSelector from "./guardrails/GuardrailSelector";
import { determineEndpointType } from "./chat_ui/EndpointUtils";
import { generateCodeSnippet } from "./chat_ui/CodeSnippets";
import { MessageType } from "./chat_ui/types";
import ReasoningContent from "./chat_ui/ReasoningContent";
import ResponseMetrics, { TokenUsage } from "./chat_ui/ResponseMetrics";
import ResponsesImageUpload from "./chat_ui/ResponsesImageUpload";
import ResponsesImageRenderer from "./chat_ui/ResponsesImageRenderer";
import { convertImageToBase64, createMultimodalMessage, createDisplayMessage } from "./chat_ui/ResponsesImageUtils";
import ChatImageUpload from "./chat_ui/ChatImageUpload";
import ChatImageRenderer from "./chat_ui/ChatImageRenderer";
import { createChatMultimodalMessage, createChatDisplayMessage } from "./chat_ui/ChatImageUtils";
import SessionManagement from "./chat_ui/SessionManagement";
import MCPEventsDisplay, { MCPEvent } from "./chat_ui/MCPEventsDisplay";
import EndpointSelector from "./EndpointSelector";
import TagSelector from "../tag_management/TagSelector";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import GuardrailSelector from "../guardrails/GuardrailSelector";
import { determineEndpointType } from "./EndpointUtils";
import { generateCodeSnippet } from "./CodeSnippets";
import { MessageType } from "./types";
import ReasoningContent from "./ReasoningContent";
import ResponseMetrics, { TokenUsage } from "./ResponseMetrics";
import ResponsesImageUpload from "./ResponsesImageUpload";
import ResponsesImageRenderer from "./ResponsesImageRenderer";
import { convertImageToBase64, createMultimodalMessage, createDisplayMessage } from "./ResponsesImageUtils";
import ChatImageUpload from "./ChatImageUpload";
import ChatImageRenderer from "./ChatImageRenderer";
import { createChatMultimodalMessage, createChatDisplayMessage } from "./ChatImageUtils";
import SessionManagement from "./SessionManagement";
import MCPEventsDisplay, { MCPEvent } from "./MCPEventsDisplay";
import {
SendOutlined,
ApiOutlined,
@@ -73,7 +73,7 @@ import {
FilePdfOutlined,
ArrowUpOutlined
} from "@ant-design/icons";
import NotificationsManager from "./molecules/notifications_manager";
import NotificationsManager from "../molecules/notifications_manager";
const { TextArea } = Input;
const { Dragger } = Upload;
@@ -282,13 +282,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
);
console.log("Fetched models:", uniqueModels);
if (uniqueModels.length > 0) {
setModelInfo(uniqueModels);
if (!selectedModel) {
setSelectedModel(uniqueModels[0].model_group);
}
setModelInfo(uniqueModels);
// check for selection overlap or empty model list
const hasSelection = uniqueModels.some(m => m.model_group === selectedModel);
if (!uniqueModels.length) {
setSelectedModel(undefined);
} else if (!hasSelection) {
setSelectedModel(uniqueModels[0].model_group);
}
} catch (error) {
console.error("Error fetching model info:", error);
}
@@ -9,13 +9,15 @@ interface GuardrailSelectorProps {
value?: string[];
className?: string;
accessToken: string;
disabled?: boolean;
}
const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
onChange,
value,
className,
accessToken
accessToken,
disabled
}) => {
const [guardrails, setGuardrails] = useState<Guardrail[]>([]);
const [loading, setLoading] = useState(false);
@@ -51,7 +53,8 @@ const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
<div>
<Select
mode="multiple"
placeholder="Select guardrails"
disabled={disabled}
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
onChange={handleGuardrailChange}
value={value}
loading={loading}
@@ -2514,10 +2514,10 @@ export const allTagNamesCall = async (accessToken: String) => {
export const allEndUsersCall = async (accessToken: String) => {
try {
let url = proxyBaseUrl
? `${proxyBaseUrl}/global/all_end_users`
: `/global/all_end_users`;
? `${proxyBaseUrl}/customer/list`
: `/customer/list`;
console.log("in global/all_end_users call", url);
console.log("in customer/list", url);
const response = await fetch(`${url}`, {
method: "GET",
headers: {
@@ -0,0 +1,338 @@
// KeyInfoView.premium-guard.test.tsx
import React from "react"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
// ---- Hoisted shared mocks (safe to use inside vi.mock factories) ----
const { keyUpdateCallMock, keyDeleteCallMock } = vi.hoisted(() => {
return {
keyUpdateCallMock: vi.fn().mockResolvedValue({}),
keyDeleteCallMock: vi.fn().mockResolvedValue({}),
}
})
// ---- Module mocks ----
// Networking: wire the hoisted fns so we can assert calls later
vi.mock("../networking", () => {
return {
keyUpdateCall: (...args: any[]) => keyUpdateCallMock(...args),
keyDeleteCall: (...args: any[]) => keyDeleteCallMock(...args),
}
})
// Notifications
vi.mock("../molecules/notifications_manager", () => {
const Notifications = {
success: vi.fn(),
error: vi.fn(),
fromBackend: vi.fn(),
}
return { default: Notifications }
})
// Roles: ensure 'admin' has write access
vi.mock("../../utils/roles", () => ({
rolesWithWriteAccess: ["admin"],
}))
// Helpers used in rendering
vi.mock("@/utils/dataUtils", () => ({
copyToClipboard: async () => true,
formatNumberWithCommas: (n: any) => String(n),
}))
vi.mock("../key_info_utils", () => ({
extractLoggingSettings: () => ({}),
formatMetadataForDisplay: (m: any) => JSON.stringify(m, null, 2),
}))
vi.mock("../callback_info_helpers", () => ({
callback_map: {},
mapInternalToDisplayNames: (x: any) => x,
mapDisplayToInternalNames: (x: any) => x,
}))
vi.mock("../shared/errorUtils", () => ({
parseErrorMessage: (e: any) => String(e),
}))
// Tremor components -> async factory, local React import, and named passthroughs
vi.mock("@tremor/react", async () => {
const React = await import("react")
const makeNamedPassthrough = (tag: any, name: string) => {
function Named(props: any) {
const { children, ...rest } = props
return React.createElement(tag, rest, children)
}
;(Named as any).displayName = name
return Named
}
const Card = makeNamedPassthrough("div", "Card")
const Text = makeNamedPassthrough("span", "Text")
const Grid = makeNamedPassthrough("div", "Grid")
const Col = makeNamedPassthrough("div", "Col")
const TabGroup = makeNamedPassthrough("div", "TabGroup")
const TabList = makeNamedPassthrough("div", "TabList")
const TabPanels = makeNamedPassthrough("div", "TabPanels")
const TabPanel = makeNamedPassthrough("div", "TabPanel")
const Title = makeNamedPassthrough("h1", "Title")
const Badge = makeNamedPassthrough("span", "Badge")
function Button(props: any) {
const { children, onClick, ...rest } = props
return React.createElement("button", { onClick, ...rest }, children)
}
;(Button as any).displayName = "Button"
function Tab(props: any) {
const { children, ...rest } = props
return React.createElement("button", { ...rest }, children)
}
;(Tab as any).displayName = "Tab"
function TextInput(props: any) {
return React.createElement("input", { ...props })
}
;(TextInput as any).displayName = "TextInput"
function TremorSelect(props: any) {
return React.createElement("select", { ...props })
}
;(TremorSelect as any).displayName = "TremorSelect"
return {
Card,
Text,
Button,
Grid,
Col,
Tab,
TabList,
TabGroup,
TabPanel,
TabPanels,
Title,
Badge,
TextInput,
Select: TremorSelect,
}
})
// antd bits -> async factory & local React
vi.mock("antd", async () => {
const React = await import("react")
const Form = { useForm: () => [{}] }
function Input(props: any) {
return React.createElement("input", { ...props })
}
;(Input as any).displayName = "AntdInput"
function InputNumber(props: any) {
return React.createElement("input", { ...props })
}
;(InputNumber as any).displayName = "AntdInputNumber"
function Select(props: any) {
return React.createElement("select", { ...props })
}
;(Select as any).displayName = "AntdSelect"
function Tooltip({ children }: any) {
return React.createElement(React.Fragment, null, children)
}
;(Tooltip as any).displayName = "AntdTooltip"
function Button(props: any) {
const { children, onClick, ...rest } = props
return React.createElement("button", { onClick, ...rest }, children)
}
;(Button as any).displayName = "AntdButton"
return { Form, Input, InputNumber, Select, Tooltip, Button }
})
// Icons -> async factory & local React
vi.mock("@heroicons/react/outline", async () => {
const React = await import("react")
function ArrowLeftIcon() {
return React.createElement("span")
}
;(ArrowLeftIcon as any).displayName = "ArrowLeftIcon"
function TrashIcon() {
return React.createElement("span")
}
;(TrashIcon as any).displayName = "TrashIcon"
function RefreshIcon() {
return React.createElement("span")
}
;(RefreshIcon as any).displayName = "RefreshIcon"
return { ArrowLeftIcon, TrashIcon, RefreshIcon }
})
vi.mock("lucide-react", async () => {
const React = await import("react")
function CopyIcon() {
return React.createElement("span")
}
;(CopyIcon as any).displayName = "CopyIcon"
function CheckIcon() {
return React.createElement("span")
}
;(CheckIcon as any).displayName = "CheckIcon"
return { CopyIcon, CheckIcon }
})
// Heavy children -> async factories & local React
vi.mock("../organisms/regenerate_key_modal", async () => {
const React = await import("react")
function RegenerateKeyModal() {
return null
}
;(RegenerateKeyModal as any).displayName = "RegenerateKeyModal"
return { RegenerateKeyModal }
})
vi.mock("../object_permissions_view", async () => {
const React = await import("react")
function ObjectPermissionsView() {
return null
}
;(ObjectPermissionsView as any).displayName = "ObjectPermissionsView"
return { __esModule: true, default: ObjectPermissionsView }
})
vi.mock("../logging_settings_view", async () => {
const React = await import("react")
function LoggingSettingsView() {
return null
}
;(LoggingSettingsView as any).displayName = "LoggingSettingsView"
return { __esModule: true, default: LoggingSettingsView }
})
vi.mock("../common_components/AutoRotationView", async () => {
const React = await import("react")
function AutoRotationView() {
return null
}
;(AutoRotationView as any).displayName = "AutoRotationView"
return { __esModule: true, default: AutoRotationView }
})
// KeyEditView mock: triggers onSubmit with our injected form values
vi.mock("./key_edit_view", async () => {
const React = await import("react")
function KeyEditView(props: any) {
return React.createElement(
"div",
null,
React.createElement(
"button",
{
onClick: () =>
props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}),
},
"Mock Submit"
)
)
}
;(KeyEditView as any).displayName = "KeyEditViewMock"
return { KeyEditView }
})
// ---- SUT import AFTER mocks ----
import KeyInfoView from "./key_info_view"
// ---- Test data helpers ----
const baseKeyData = {
token_id: "tok_123",
token: "tok_123",
key_alias: "My API Key",
key_name: "sk-xxxx",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
spend: 0,
max_budget: null,
tpm_limit: null,
rpm_limit: null,
models: [] as string[],
metadata: {} as Record<string, any>,
object_permission: {} as Record<string, any>,
auto_rotate: false,
rotation_interval: null as any,
last_rotation_at: null as any,
key_rotation_at: null as any,
next_rotation_at: null as any,
}
const renderView = (premiumUser: boolean) =>
render(
<KeyInfoView
keyId="tok_123"
onClose={() => {}}
keyData={baseKeyData as any}
onKeyDataUpdate={() => {}}
accessToken="access_abc"
userID="user_1"
userRole="admin"
teams={[]}
premiumUser={premiumUser}
setAccessToken={() => {}}
/>
)
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as any).__TEST_FORM_VALUES = undefined
})
// ---- Tests ----
describe("KeyInfoView handleKeyUpdate premium guard", () => {
it("removes guardrails & prompts for non-premium users and prevents metadata.guardrails", async () => {
renderView(false) // premiumUser = false
fireEvent.click(screen.getByText("Edit Settings"))
;(globalThis as any).__TEST_FORM_VALUES = {
token: "tok_123",
guardrails: ["gr-1", "gr-2"],
prompts: ["fast", "safe"],
metadata: {}, // object form (not JSON string)
}
fireEvent.click(screen.getByText("Mock Submit"))
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled())
const [sentAccessToken, sentPayload] = keyUpdateCallMock.mock.calls[0]
expect(sentAccessToken).toBe("access_abc")
expect("guardrails" in sentPayload).toBe(false)
expect("prompts" in sentPayload).toBe(false)
expect(sentPayload.metadata?.guardrails).toBeUndefined()
expect(sentPayload.key).toBe("tok_123")
})
it("preserves guardrails & prompts for premium users and includes metadata.guardrails", async () => {
renderView(true) // premiumUser = true
fireEvent.click(screen.getByText("Edit Settings"))
;(globalThis as any).__TEST_FORM_VALUES = {
token: "tok_123",
guardrails: ["gr-1"],
prompts: ["fast"],
metadata: {},
}
fireEvent.click(screen.getByText("Mock Submit"))
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled())
const [, sentPayload] = keyUpdateCallMock.mock.calls[0]
expect(sentPayload.guardrails).toEqual(["gr-1"])
expect(sentPayload.prompts).toEqual(["fast"])
expect(sentPayload.metadata?.guardrails).toEqual(["gr-1"])
expect(sentPayload.key).toBe("tok_123")
})
})
@@ -137,8 +137,8 @@ export function KeyEditView({
token: keyData.token || keyData.token_id,
budget_duration: getBudgetDuration(keyData.budget_duration),
metadata: formatMetadataForDisplay(keyData.metadata),
guardrails: keyData.metadata?.guardrails || [],
prompts: keyData.metadata?.prompts || [],
guardrails: keyData.metadata?.guardrails,
prompts: keyData.metadata?.prompts,
vector_stores: keyData.object_permission?.vector_stores || [],
mcp_servers_and_groups: {
servers: keyData.object_permission?.mcp_servers || [],
@@ -158,8 +158,8 @@ export function KeyEditView({
token: keyData.token || keyData.token_id,
budget_duration: getBudgetDuration(keyData.budget_duration),
metadata: formatMetadataForDisplay(keyData.metadata),
guardrails: keyData.metadata?.guardrails || [],
prompts: keyData.metadata?.prompts || [],
guardrails: keyData.metadata?.guardrails,
prompts: keyData.metadata?.prompts,
vector_stores: keyData.object_permission?.vector_stores || [],
mcp_servers_and_groups: {
servers: keyData.object_permission?.mcp_servers || [],
@@ -240,7 +240,7 @@ export function KeyEditView({
<Form.Item label="Guardrails" name="guardrails">
{ accessToken &&
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} />
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} disabled={!premiumUser}/>
}
</Form.Item>
@@ -109,6 +109,12 @@ export default function KeyInfoView({
const currentKey = formValues.token
formValues.key = currentKey
// Guard premium features
if (!premiumUser) {
delete formValues.guardrails;
delete formValues.prompts;
}
// Handle object_permission updates
if (formValues.vector_stores !== undefined) {
formValues.object_permission = {
@@ -635,7 +635,7 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
let all_end_users_data = await allEndUsersCall(accessToken)
setAllEndUsers(all_end_users_data?.end_users)
setAllEndUsers(all_end_users_data?.map((u: any) => u.user_id))
const routerSettingsInfo = await getCallbacksCall(accessToken, userID, userRole)
@@ -419,7 +419,8 @@ export default function SpendLogsTable({
searchFn: async (searchText: string) => {
if (!accessToken) return []
const data = await allEndUsersCall(accessToken)
const users = data?.end_users || []
// data if set, is a list of objects, with key = user_id
const users = data?.map((u: any) => u.user_id) || []
const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase()))
return filtered.map((u: string) => ({ label: u, value: u }))
},
@@ -55,7 +55,13 @@ export function useLogFilterLogic({
}), []);
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const [filteredLogs, setFilteredLogs] = useState<PaginatedResponse>(logs);
const [backendFilteredLogs, setBackendFilteredLogs] = useState<PaginatedResponse>({
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0
});
const lastSearchTimestamp = useRef(0);
const performSearch = useCallback(async (filters: LogFilterState, page = 1) => {
if (!accessToken) return;
@@ -87,7 +93,7 @@ export function useLogFilterLogic({
);
if (currentTimestamp === lastSearchTimestamp.current && response.data) {
setFilteredLogs(response);
setBackendFilteredLogs(response);
}
} catch (error) {
console.error("Error searching users:", error);
@@ -113,34 +119,36 @@ export function useLogFilterLogic({
});
const allKeyAliases = queryAllKeysQuery.data || []
// Apply filters to keys whenever logs or filters change
useEffect(() => {
// Determine when backend filters are active (server-side filtering)
const hasBackendFilters = useMemo(() => (
!!(
filters[FILTER_KEYS.KEY_ALIAS] ||
filters[FILTER_KEYS.KEY_HASH] ||
filters[FILTER_KEYS.REQUEST_ID] ||
filters[FILTER_KEYS.USER_ID] ||
filters[FILTER_KEYS.END_USER]
)
), [filters]);
// Compute client-side filtered logs directly from incoming logs and filters
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
if (!logs || !logs.data) {
setFilteredLogs({
return {
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0
});
return;
};
}
// Only do client-side filtering if no backend filters are active
const hasBackendFilters =
filters[FILTER_KEYS.KEY_ALIAS] ||
filters[FILTER_KEYS.KEY_HASH] ||
filters[FILTER_KEYS.REQUEST_ID] ||
filters[FILTER_KEYS.USER_ID] ||
filters[FILTER_KEYS.END_USER];
// If backend filters are on, don't perform client-side filtering here
if (hasBackendFilters) {
// Backend is handling filtering, don't override the results
return;
return logs;
}
let filteredData = [...logs.data];
if (filters[FILTER_KEYS.TEAM_ID]) {
filteredData = filteredData.filter(
log => log.team_id === filters[FILTER_KEYS.TEAM_ID]
@@ -163,7 +171,7 @@ export function useLogFilterLogic({
log => log.model === filters[FILTER_KEYS.MODEL]
);
}
if (filters[FILTER_KEYS.KEY_HASH]) {
filteredData = filteredData.filter(
log => log.api_key === filters[FILTER_KEYS.KEY_HASH]
@@ -175,21 +183,33 @@ export function useLogFilterLogic({
log => log.end_user === filters[FILTER_KEYS.END_USER]
);
}
const newFilteredLogs: PaginatedResponse = {
return {
data: filteredData,
total: logs.total,
page: logs.page,
page_size: logs.page_size,
total_pages: logs.total_pages,
};
if (JSON.stringify(newFilteredLogs) !== JSON.stringify(filteredLogs)) {
setFilteredLogs(newFilteredLogs);
}
}, [logs, filters, filteredLogs, accessToken]);
}, [logs, filters, hasBackendFilters]);
// Choose which filtered logs to expose: backend result when active, otherwise client-derived
const filteredLogs: PaginatedResponse = useMemo(() => {
if (hasBackendFilters) {
// Prefer backend result if present; otherwise fall back to latest logs
if (backendFilteredLogs && backendFilteredLogs.data && backendFilteredLogs.data.length > 0) {
return backendFilteredLogs;
}
return logs || {
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0
};
}
return clientDerivedFilteredLogs;
}, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs, logs]);
// Fetch all teams and users for potential filter dropdowns (optional, can be adapted)
const { data: allTeams } = useQuery<Team[], Error>({
@@ -231,6 +251,15 @@ export function useLogFilterLogic({
// Reset filters state
setFilters(defaultFilters);
// Clear backend filtered logs to ensure fresh render
setBackendFilteredLogs({
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0
});
// Reset selections
debouncedSearch(defaultFilters, 1);
};
+61 -25
View File
@@ -1,43 +1,79 @@
import NotificationsManager from "@/components/molecules/notifications_manager";
import { message } from "antd";
import NotificationsManager from "@/components/molecules/notifications_manager"
import { message } from "antd"
export function updateExistingKeys<Source extends Object>(target: Source, source: Object): Source {
const clonedTarget = structuredClone(target)
export function updateExistingKeys<Source extends Object>(
target: Source,
source: Object
): Source {
const clonedTarget = structuredClone(target);
for (const [key, value] of Object.entries(source)) {
if (key in clonedTarget) {
(clonedTarget as any)[key] = value;
;(clonedTarget as any)[key] = value
}
}
return clonedTarget;
return clonedTarget
}
export const formatNumberWithCommas = (value: number | null | undefined, decimals: number = 0): string => {
if (value === null || value === undefined) {
return '-';
return "-"
}
return value.toLocaleString('en-US', {
return value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
})
}
export const copyToClipboard = async (
text: string | null | undefined,
messageText: string = "Copied to clipboard"
messageText: string = "Copied to clipboard",
): Promise<boolean> => {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
NotificationsManager.success(messageText);
return true;
} catch (err) {
NotificationsManager.fromBackend("Failed to copy to clipboard");
console.error("Failed to copy: ", err);
return false;
if (!text) return false
// Check if clipboard API is available
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text)
NotificationsManager.success(messageText)
return true
} catch (err) {
console.error("Clipboard API failed: ", err)
// Fall back to legacy method
return fallbackCopyToClipboard(text, messageText)
}
} else {
// Use fallback method when clipboard API is not available
return fallbackCopyToClipboard(text, messageText)
}
};
}
// Fallback method using document.execCommand (deprecated but widely supported)
const fallbackCopyToClipboard = (text: string, messageText: string): boolean => {
try {
const textArea = document.createElement("textarea")
textArea.value = text
// Make the textarea invisible
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
textArea.style.top = "-999999px"
textArea.setAttribute("readonly", "")
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand("copy")
document.body.removeChild(textArea)
if (successful) {
NotificationsManager.success(messageText)
return true
} else {
throw new Error("execCommand failed")
}
} catch (err) {
NotificationsManager.fromBackend("Failed to copy to clipboard")
console.error("Failed to copy: ", err)
return false
}
}
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
import { copyToClipboard, formatNumberWithCommas, updateExistingKeys } from "../../src/utils/dataUtils"
// Mock NotificationsManager
vi.mock("../../src/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),
},
}))
// Import the mocked module
import NotificationsManager from "../../src/components/molecules/notifications_manager"
const mockNotificationsManager = vi.mocked(NotificationsManager)
describe("dataUtils", () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset document.execCommand mock
delete (document as any).execCommand
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("updateExistingKeys", () => {
it("should update only existing keys in target object", () => {
const target = { a: 1, b: 2, c: 3 }
const source = { a: 10, b: 20, d: 40 }
const result = updateExistingKeys(target, source)
expect(result).toEqual({ a: 10, b: 20, c: 3 })
expect(result).not.toBe(target) // Should be a clone
})
it("should not modify original target object", () => {
const target = { a: 1, b: 2 }
const source = { a: 10, c: 30 }
updateExistingKeys(target, source)
expect(target).toEqual({ a: 1, b: 2 }) // Original unchanged
})
it("should handle empty source object", () => {
const target = { a: 1, b: 2 }
const source = {}
const result = updateExistingKeys(target, source)
expect(result).toEqual({ a: 1, b: 2 })
})
})
describe("formatNumberWithCommas", () => {
it("should format numbers with commas", () => {
expect(formatNumberWithCommas(1234567)).toBe("1,234,567")
expect(formatNumberWithCommas(1000)).toBe("1,000")
expect(formatNumberWithCommas(123)).toBe("123")
})
it("should handle decimals", () => {
expect(formatNumberWithCommas(1234.5678, 2)).toBe("1,234.57")
expect(formatNumberWithCommas(1000.123, 3)).toBe("1,000.123")
})
it("should handle null and undefined values", () => {
expect(formatNumberWithCommas(null)).toBe("-")
expect(formatNumberWithCommas(undefined)).toBe("-")
})
it("should handle zero", () => {
expect(formatNumberWithCommas(0)).toBe("0")
expect(formatNumberWithCommas(0, 2)).toBe("0.00")
})
})
describe("copyToClipboard", () => {
describe("when Clipboard API is available (HTTPS scenario)", () => {
beforeEach(() => {
// Mock modern Clipboard API
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
})
it("should use navigator.clipboard.writeText when available", async () => {
const result = await copyToClipboard("test text")
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text")
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Copied to clipboard")
expect(result).toBe(true)
})
it("should use custom message when provided", async () => {
await copyToClipboard("test text", "Custom message")
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Custom message")
})
it("should return false for null/undefined text", async () => {
expect(await copyToClipboard(null)).toBe(false)
expect(await copyToClipboard(undefined)).toBe(false)
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
it("should fall back to execCommand when clipboard API fails", async () => {
// Make clipboard API fail
navigator.clipboard.writeText = vi.fn().mockRejectedValue(new Error("Permission denied"))
// Mock successful execCommand
document.execCommand = vi.fn().mockReturnValue(true)
// Mock DOM methods
const mockTextArea = {
value: "",
style: {},
setAttribute: vi.fn(),
focus: vi.fn(),
select: vi.fn(),
}
document.createElement = vi.fn().mockReturnValue(mockTextArea)
document.body.appendChild = vi.fn()
document.body.removeChild = vi.fn()
const result = await copyToClipboard("test text")
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text")
expect(document.execCommand).toHaveBeenCalledWith("copy")
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Copied to clipboard")
expect(result).toBe(true)
})
})
describe("when Clipboard API is not available (HTTP scenario)", () => {
beforeEach(() => {
// Mock HTTP scenario - no clipboard API
Object.assign(navigator, {
clipboard: undefined,
})
// Mock successful execCommand
document.execCommand = vi.fn().mockReturnValue(true)
// Mock DOM methods
const mockTextArea = {
value: "",
style: {},
setAttribute: vi.fn(),
focus: vi.fn(),
select: vi.fn(),
}
document.createElement = vi.fn().mockReturnValue(mockTextArea)
document.body.appendChild = vi.fn()
document.body.removeChild = vi.fn()
})
it("should fall back to execCommand when clipboard API is not available", async () => {
const result = await copyToClipboard("test text")
expect(document.createElement).toHaveBeenCalledWith("textarea")
expect(document.execCommand).toHaveBeenCalledWith("copy")
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Copied to clipboard")
expect(result).toBe(true)
})
it("should set textarea properties correctly", async () => {
const mockTextArea = {
value: "",
style: {},
setAttribute: vi.fn(),
focus: vi.fn(),
select: vi.fn(),
}
document.createElement = vi.fn().mockReturnValue(mockTextArea)
await copyToClipboard("test text")
expect(mockTextArea.value).toBe("test text")
expect(mockTextArea.style.position).toBe("fixed")
expect(mockTextArea.style.left).toBe("-999999px")
expect(mockTextArea.style.top).toBe("-999999px")
expect(mockTextArea.setAttribute).toHaveBeenCalledWith("readonly", "")
expect(mockTextArea.focus).toHaveBeenCalled()
expect(mockTextArea.select).toHaveBeenCalled()
})
it("should handle execCommand failure", async () => {
document.execCommand = vi.fn().mockReturnValue(false)
const result = await copyToClipboard("test text")
expect(document.execCommand).toHaveBeenCalledWith("copy")
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to copy to clipboard")
expect(result).toBe(false)
})
it("should handle DOM manipulation errors", async () => {
document.createElement = vi.fn().mockImplementation(() => {
throw new Error("DOM error")
})
const result = await copyToClipboard("test text")
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to copy to clipboard")
expect(result).toBe(false)
})
it("should clean up textarea element after successful copy", async () => {
const mockTextArea = {
value: "",
style: {},
setAttribute: vi.fn(),
focus: vi.fn(),
select: vi.fn(),
}
document.createElement = vi.fn().mockReturnValue(mockTextArea)
await copyToClipboard("test text")
expect(document.body.appendChild).toHaveBeenCalledWith(mockTextArea)
expect(document.body.removeChild).toHaveBeenCalledWith(mockTextArea)
})
})
describe("edge cases", () => {
beforeEach(() => {
// Mock scenario where navigator exists but clipboard is null
Object.assign(navigator, {
clipboard: null,
})
document.execCommand = vi.fn().mockReturnValue(true)
const mockTextArea = {
value: "",
style: {},
setAttribute: vi.fn(),
focus: vi.fn(),
select: vi.fn(),
}
document.createElement = vi.fn().mockReturnValue(mockTextArea)
document.body.appendChild = vi.fn()
document.body.removeChild = vi.fn()
})
it("should handle navigator.clipboard being null", async () => {
const result = await copyToClipboard("test text")
expect(document.execCommand).toHaveBeenCalledWith("copy")
expect(result).toBe(true)
})
it("should handle navigator.clipboard.writeText being undefined", async () => {
Object.assign(navigator, {
clipboard: {}, // clipboard exists but writeText doesn't
})
const result = await copyToClipboard("test text")
expect(document.execCommand).toHaveBeenCalledWith("copy")
expect(result).toBe(true)
})
})
})
})
@@ -0,0 +1,57 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useLogFilterLogic } from "../../src/components/view_logs/log_filter_logic";
// Minimal mocks to avoid real network during hook init
vi.mock("../../src/components/key_team_helpers/filter_helpers", () => ({
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
const createQueryClient = () => new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
function Harness({ logs }: { logs: any }) {
const { filteredLogs } = useLogFilterLogic({
logs,
accessToken: "token",
startTime: "2025-01-01 00:00:00",
endTime: "2025-01-02 00:00:00",
pageSize: 50,
isCustomDate: true,
setCurrentPage: () => {},
userID: "user-1",
userRole: "admin",
});
return <div data-testid="count">{filteredLogs.data.length}</div>;
}
describe("useLogFilterLogic (minimal)", () => {
it("useLogFilterLogic minimal: updates filteredLogs when logs change", async () => {
const qc = createQueryClient();
const logsA = { data: [{ request_id: "a" }], total: 1, page: 1, page_size: 50, total_pages: 1 };
const logsB = { data: [{ request_id: "a" }, { request_id: "b" }], total: 2, page: 1, page_size: 50, total_pages: 1 };
const { rerender } = render(
<QueryClientProvider client={qc}>
<Harness logs={logsA} />
</QueryClientProvider>
);
expect(await screen.findByTestId("count")).toHaveTextContent("1");
rerender(
<QueryClientProvider client={qc}>
<Harness logs={logsB} />
</QueryClientProvider>
);
expect(await screen.findByTestId("count")).toHaveTextContent("2");
});
});