mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_skill_marketplaces
# Conflicts: # ui/litellm-dashboard/eslint-suppressions.json
This commit is contained in:
commit
3d24e41121
65 changed files with 773 additions and 413 deletions
|
|
@ -49,7 +49,11 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus
|
||||
from litellm.types.utils import (
|
||||
GenericGuardrailAPIInputs,
|
||||
GuardrailStatus,
|
||||
StandardLoggingGuardrailInformation,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
|
|
@ -246,16 +250,21 @@ class XecGuardGuardrail(CustomGuardrail):
|
|||
"guardrail_intervened" if scan_result.get("decision") == "UNSAFE" else "success"
|
||||
)
|
||||
end_time = datetime.now()
|
||||
kwargs["standard_logging_object"]["guardrail_information"] = {
|
||||
"duration": (end_time - start_time).total_seconds(),
|
||||
"end_time": end_time.timestamp(),
|
||||
"guardrail_mode": "logging_only",
|
||||
"guardrail_name": "xecguard",
|
||||
"guardrail_response": scan_result,
|
||||
"guardrail_status": guardrail_status,
|
||||
"masked_entity_count": None,
|
||||
"start_time": start_time.timestamp(),
|
||||
}
|
||||
slg = StandardLoggingGuardrailInformation(
|
||||
guardrail_name=self.guardrail_name or "xecguard",
|
||||
guardrail_mode=GuardrailEventHooks.logging_only,
|
||||
guardrail_response=scan_result,
|
||||
guardrail_status=guardrail_status,
|
||||
start_time=start_time.timestamp(),
|
||||
end_time=end_time.timestamp(),
|
||||
duration=(end_time - start_time).total_seconds(),
|
||||
masked_entity_count=None,
|
||||
)
|
||||
existing = kwargs["standard_logging_object"].get("guardrail_information")
|
||||
if isinstance(existing, list):
|
||||
existing.append(slg)
|
||||
else:
|
||||
kwargs["standard_logging_object"]["guardrail_information"] = [slg]
|
||||
|
||||
except Exception as exc:
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
|
|||
|
|
@ -1644,12 +1644,37 @@ class TestXecGuardLoggingHook:
|
|||
)
|
||||
assert out_kwargs is kwargs
|
||||
assert out_result is result
|
||||
info = kwargs["standard_logging_object"]["guardrail_information"]
|
||||
info_list = kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert isinstance(info_list, list), "guardrail_information must be a list"
|
||||
assert len(info_list) == 1
|
||||
info = info_list[0]
|
||||
assert info["guardrail_mode"] == "logging_only"
|
||||
assert info["guardrail_name"] == "xecguard"
|
||||
assert info["guardrail_name"] == "test-xecguard"
|
||||
assert info["guardrail_status"] == "success"
|
||||
assert info["guardrail_response"]["trace_id"] == "lg-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_logging_hook_appends_to_existing_guardrail_info(
|
||||
self, xecguard_guardrail, mock_request_data
|
||||
):
|
||||
resp = _make_response({"decision": "SAFE", "trace_id": "lg-4"})
|
||||
prior_entry = {"guardrail_name": "other-guardrail"}
|
||||
with patch.object(xecguard_guardrail.async_handler, "post", return_value=resp):
|
||||
kwargs = {
|
||||
**mock_request_data,
|
||||
"standard_logging_object": {"guardrail_information": [prior_entry]},
|
||||
}
|
||||
await xecguard_guardrail.async_logging_hook(
|
||||
kwargs=kwargs,
|
||||
result=_build_model_response("some answer"),
|
||||
call_type="acompletion",
|
||||
)
|
||||
info_list = kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert len(info_list) == 2
|
||||
assert info_list[0] is prior_entry
|
||||
assert info_list[1]["guardrail_name"] == "test-xecguard"
|
||||
assert info_list[1]["guardrail_response"]["trace_id"] == "lg-4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_logging_hook_without_response_records_info(
|
||||
self, xecguard_guardrail, mock_request_data
|
||||
|
|
@ -1680,7 +1705,9 @@ class TestXecGuardLoggingHook:
|
|||
result=_build_model_response("x"),
|
||||
call_type="acompletion",
|
||||
)
|
||||
info = kwargs["standard_logging_object"]["guardrail_information"]
|
||||
info_list = kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert isinstance(info_list, list), "guardrail_information must be a list"
|
||||
info = info_list[0]
|
||||
assert info["guardrail_status"] == "guardrail_intervened"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -515,6 +515,152 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -1073,16 +1219,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
@ -1872,40 +2008,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/MCPLogoSelector.test.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/MCPNetworkSettings.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/MCPSubmissionsTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/MCPToolArgumentsForm.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/MCPToolsetsTab.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/McpCrudPermissionPanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
|
|
@ -1914,123 +2021,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/OAuthFormFields.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/OpenAPIQuickPicker.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/ToolTestPanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/UserEnvVarsModal.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/create_mcp_server.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_connect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_connection_status.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_discovery.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_server_cost_config.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_server_cost_display.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_server_edit.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_server_view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_servers.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_tool_configuration.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_tools.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/model_add/AddCredentialModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
14
ui/litellm-dashboard/package-lock.json
generated
14
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -29,6 +29,7 @@
|
|||
"next": "16.2.6",
|
||||
"openai": "4.104.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4",
|
||||
"papaparse": "5.5.3",
|
||||
"react": "18.3.1",
|
||||
"react-copy-to-clipboard": "5.1.1",
|
||||
|
|
@ -10544,6 +10545,19 @@
|
|||
"openapi-typescript-helpers": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-react-query": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/openapi-react-query/-/openapi-react-query-0.5.4.tgz",
|
||||
"integrity": "sha512-V9lRiozjHot19/BYSgXYoyznDxDJQhEBSdi26+SJ0UqjMANLQhkni4XG+Z7e3Ag7X46ZLMrL9VxYkghU3QvbWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"openapi-fetch": "^0.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"next": "16.2.6",
|
||||
"openai": "4.104.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4",
|
||||
"papaparse": "5.5.3",
|
||||
"react": "18.3.1",
|
||||
"react-copy-to-clipboard": "5.1.1",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCustomers, type EndUser } from "./useCustomers";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@/lib/http/api", () => ({
|
||||
fetchClient: { GET: (...args: unknown[]) => mockGet(...args) },
|
||||
$api: { useQuery: (...args: unknown[]) => useQueryMock(...args) },
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
|
|
@ -14,91 +12,55 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
const mockCustomers: EndUser[] = [
|
||||
{ user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false },
|
||||
{ user_id: "customer-2", alias: null, spend: 0, blocked: true },
|
||||
];
|
||||
const authorized = { accessToken: "test-access-token", userRole: "Admin" };
|
||||
|
||||
const authorized = {
|
||||
accessToken: "test-access-token",
|
||||
userRole: "Admin",
|
||||
userId: "test-user-id",
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
type QueryOptions = { enabled: boolean; select: (data: EndUser[] | undefined) => EndUser[] };
|
||||
|
||||
const lastCallOptions = (): QueryOptions => {
|
||||
const calls = useQueryMock.mock.calls;
|
||||
return calls[calls.length - 1][3] as QueryOptions;
|
||||
};
|
||||
|
||||
describe("useCustomers", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.clearAllMocks();
|
||||
useQueryMock.mockReturnValue({ data: [] });
|
||||
mockUseAuthorized.mockReturnValue(authorized);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("fetches /customer/list and returns the typed list on success", async () => {
|
||||
mockGet.mockResolvedValue({ data: mockCustomers });
|
||||
|
||||
const { result } = renderHook(() => useCustomers(), { wrapper });
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockCustomers);
|
||||
expect(mockGet).toHaveBeenCalledWith("/customer/list");
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
it("queries GET /customer/list with a derived key (no hand-written queryKey)", () => {
|
||||
renderHook(() => useCustomers());
|
||||
expect(useQueryMock).toHaveBeenCalledWith("get", "/customer/list", {}, expect.any(Object));
|
||||
});
|
||||
|
||||
it("surfaces an error when the request rejects", async () => {
|
||||
const testError = new Error("Failed to fetch customers");
|
||||
mockGet.mockRejectedValue(testError);
|
||||
|
||||
const { result } = renderHook(() => useCustomers(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toEqual(testError);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
it("enables the query only for an admin holding an access token", () => {
|
||||
renderHook(() => useCustomers());
|
||||
expect(lastCallOptions().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to an empty list when the response has no body", async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useCustomers(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
it("disables the query when the access token is missing", () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null });
|
||||
renderHook(() => useCustomers());
|
||||
expect(lastCallOptions().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fetch when the access token is missing", () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null, token: null });
|
||||
|
||||
const { result } = renderHook(() => useCustomers(), { wrapper });
|
||||
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch when the user is not an admin", () => {
|
||||
it("disables the query for a non-admin role", () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...authorized, userRole: "member" });
|
||||
renderHook(() => useCustomers());
|
||||
expect(lastCallOptions().enabled).toBe(false);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCustomers(), { wrapper });
|
||||
it("selects an empty list when the response body is missing", () => {
|
||||
renderHook(() => useCustomers());
|
||||
expect(lastCallOptions().select(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
it("selects the customer list through unchanged", () => {
|
||||
const customers: EndUser[] = [
|
||||
{ user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false },
|
||||
{ user_id: "customer-2", alias: null, spend: 0, blocked: true },
|
||||
];
|
||||
renderHook(() => useCustomers());
|
||||
expect(lastCallOptions().select(customers)).toEqual(customers);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type EndUser = components["schemas"]["CustomerResponse"];
|
||||
|
||||
const customersKeys = createQueryKeys("customers");
|
||||
|
||||
export const useCustomers = () => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
return useQuery({
|
||||
queryKey: customersKeys.list({}),
|
||||
queryFn: async () => (await fetchClient.GET("/customer/list")).data ?? [],
|
||||
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
|
||||
});
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/customer/list",
|
||||
{},
|
||||
{
|
||||
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
|
||||
select: (data) => data ?? [],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { Form, Switch, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { isClientForwardedTokenMode } from "./types";
|
||||
import { isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
/**
|
||||
* DCR-bridge toggle for the client-forwarded token modes (true_passthrough /
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Button, Card, Typography, Spin, Tag } from "antd";
|
||||
import { SaveOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { DeprecationBanner } from "../DeprecationBanner";
|
||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||
import {
|
||||
getGeneralSettingsCall,
|
||||
updateConfigFieldSetting,
|
||||
deleteConfigFieldSetting,
|
||||
fetchMCPClientIp,
|
||||
} from "../networking";
|
||||
} from "@/components/networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
|
||||
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { MCPServer, AUTH_TYPE } from "./types";
|
||||
import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface MCPPermissionManagementProps {
|
||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import MCPServerCard from "./MCPServerCard";
|
||||
import type { MCPServer } from "./types";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
const baseServer: MCPServer = {
|
||||
server_id: "srv-1",
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
MoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { AUTH_TYPE, type MCPServer } from "./types";
|
||||
import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS, SETTINGS_KEY } from "./MCPStandardsSettings";
|
||||
import { MCPServer } from "./types";
|
||||
import { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
const makeServer = (overrides: Partial<MCPServer> = {}): MCPServer => ({
|
||||
server_id: "s1",
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MCPServer } from "./types";
|
||||
import { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
export interface RequiredFieldDef {
|
||||
key: string;
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
getGeneralSettingsCall,
|
||||
updateConfigFieldSetting,
|
||||
} from "@/components/networking";
|
||||
import { MCPServer, MCPSubmissionsSummary } from "./types";
|
||||
import { MCPServer, MCPSubmissionsSummary } from "@/components/mcp_tools/types";
|
||||
import { FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS, SETTINGS_KEY } from "./MCPStandardsSettings";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
|
|
@ -7,9 +7,15 @@ import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolset
|
|||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { DataTable } from "../view_logs/table";
|
||||
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools, getProxyBaseUrl } from "../networking";
|
||||
import { MCPToolset, MCPToolsetTool } from "./types";
|
||||
import { DataTable } from "@/components/view_logs/table";
|
||||
import {
|
||||
createMCPToolset,
|
||||
updateMCPToolset,
|
||||
deleteMCPToolset,
|
||||
listMCPTools,
|
||||
getProxyBaseUrl,
|
||||
} from "@/components/networking";
|
||||
import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
|
||||
|
||||
const { Text: AntdText } = Typography;
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { Form, Input, InputNumber, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { OAUTH_FLOW } from "./types";
|
||||
import { OAUTH_FLOW } from "@/components/mcp_tools/types";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
|
||||
interface OAuthFlowStatus {
|
||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||
import { Form, Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { FormInstance } from "antd/es/form";
|
||||
import { AUTH_TYPE, OAUTH_FLOW } from "./types";
|
||||
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
|
||||
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
|
||||
|
||||
interface OpenAPIFormSectionProps {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Spin } from "antd";
|
||||
import { fetchOpenAPIRegistry } from "../networking";
|
||||
import { fetchOpenAPIRegistry } from "@/components/networking";
|
||||
|
||||
export interface OpenAPIKeyTool {
|
||||
name: string;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { Button, Checkbox, Form, Input } from "antd";
|
||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||
import { credentialAuthClass, isClientForwardedTokenMode } from "./types";
|
||||
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
interface PassthroughOAuthFlow {
|
||||
startOAuthFlow: () => void | Promise<void>;
|
||||
|
|
@ -3,9 +3,9 @@ import { render, screen } from "@testing-library/react";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ToolTestPanel } from "./ToolTestPanel";
|
||||
import { InputSchema, MCPTool } from "./types";
|
||||
import { InputSchema, MCPTool } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import React from "react";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
|
||||
import { MCPTool, InputSchema, InputSchemaProperty } from "@/components/mcp_tools/types";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import { Form, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { Alert } from "antd";
|
||||
import { AUTH_TYPE } from "./types";
|
||||
import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
|
||||
/**
|
||||
* Warning shown in the create/edit MCP server forms when auth_type
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import React from "react";
|
||||
import { Modal, Form, Input, Button, Alert, Spin, Tag, Typography } from "antd";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { MCPServer, MCPUserEnvVarsStatus } from "./types";
|
||||
import { getMCPUserEnvVars, storeMCPUserEnvVars } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { MCPServer, MCPUserEnvVarsStatus } from "@/components/mcp_tools/types";
|
||||
import { getMCPUserEnvVars, storeMCPUserEnvVars } from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as networking from "../networking";
|
||||
import * as networking from "@/components/networking";
|
||||
import { setToken } from "@/utils/mcpTokenStore";
|
||||
import CreateMCPServer from "./create_mcp_server";
|
||||
import { selectAntOption } from "./testUtils";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createMCPServer: vi.fn(),
|
||||
fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }),
|
||||
registerMCPServer: vi.fn(),
|
||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||
import { Modal, Tooltip, Form, Select, Input, InputNumber, Switch, Collapse } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "../networking";
|
||||
import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "@/components/networking";
|
||||
import { setToken } from "@/utils/mcpTokenStore";
|
||||
import {
|
||||
AUTH_TYPE,
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
isHeldOAuthTokenStale,
|
||||
preservedDeclaredAppCredentials,
|
||||
withoutMintedTokenCredentials,
|
||||
} from "./types";
|
||||
} from "@/components/mcp_tools/types";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
|
|
@ -35,7 +35,7 @@ import MCPLogoSelector from "./MCPLogoSelector";
|
|||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { validateMCPServerUrl, validateMCPServerName, normalizeEnvVars, TOOL_DISPLAY_NAME_PATTERN } from "./utils";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
|
||||
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
|
||||
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
|
||||
|
|
@ -4,8 +4,8 @@ import React, { useState } from "react";
|
|||
import { Card, Typography, Space, Alert, Button, Switch, Form, Collapse } from "antd";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text as TremorText } from "@tremor/react";
|
||||
import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react";
|
||||
import { getProxyBaseUrl } from "../networking";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Modal, Input, Typography } from "antd";
|
||||
import { fetchDiscoverableMCPServers } from "../networking";
|
||||
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "./types";
|
||||
import { fetchDiscoverableMCPServers } from "@/components/networking";
|
||||
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types";
|
||||
import { mcpLogoImg } from "./create_mcp_server";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { Tooltip, InputNumber, Collapse, Badge } from "antd";
|
||||
import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { MCPServerCostInfo } from "./types";
|
||||
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
|
||||
|
||||
interface MCPServerCostConfigProps {
|
||||
value?: MCPServerCostInfo;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import { MCPServerCostInfo } from "./types";
|
||||
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
|
||||
|
||||
interface MCPServerCostDisplayProps {
|
||||
costConfig?: MCPServerCostInfo | null;
|
||||
|
|
@ -4,18 +4,18 @@ import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit";
|
||||
import { setSecureItem } from "@/utils/secureStorage";
|
||||
import * as networking from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import * as networking from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { selectAntOption } from "./testUtils";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
vi.mock("@/components/networking", () => ({
|
||||
updateMCPServer: vi.fn(),
|
||||
listMCPTools: vi.fn().mockResolvedValue({ tools: [], error: null }),
|
||||
storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}),
|
||||
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
|
|
@ -18,8 +18,13 @@ import {
|
|||
TRANSPORT,
|
||||
getMcpOAuthMode,
|
||||
oauth2FlowToFormValue,
|
||||
} from "./types";
|
||||
import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential, testMCPToolsListRequest } from "../networking";
|
||||
} from "@/components/mcp_tools/types";
|
||||
import {
|
||||
updateMCPServer,
|
||||
listMCPTools,
|
||||
storeMCPOAuthUserCredential,
|
||||
testMCPToolsListRequest,
|
||||
} from "@/components/networking";
|
||||
import { getToken, isTokenValid, removeToken, setToken } from "@/utils/mcpTokenStore";
|
||||
import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
|
|
@ -39,7 +44,7 @@ import {
|
|||
normalizeToolOverrideMap,
|
||||
TOOL_DISPLAY_NAME_PATTERN,
|
||||
} from "./utils";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
|
||||
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||
import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline";
|
||||
import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react";
|
||||
|
||||
import { MCPServer, handleTransport, handleAuth } from "./types";
|
||||
import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
|
||||
// TODO: Move Tools viewer from index file
|
||||
import { MCPToolsViewer } from ".";
|
||||
import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit";
|
||||
|
|
@ -3,10 +3,10 @@ import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import MCPServers from "./mcp_servers";
|
||||
import * as networking from "../networking";
|
||||
import * as networking from "@/components/networking";
|
||||
|
||||
// Mock the networking module
|
||||
vi.mock("../networking", () => ({
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchMCPServers: vi.fn(),
|
||||
fetchMCPServerHealth: vi.fn(),
|
||||
deleteMCPServer: vi.fn(),
|
||||
|
|
@ -19,7 +19,7 @@ vi.mock("../networking", () => ({
|
|||
}));
|
||||
|
||||
// Mock NotificationsManager
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
|
|
@ -1,29 +1,35 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
|
||||
import NewBadge from "../common_components/NewBadge";
|
||||
import NewBadge from "@/components/common_components/NewBadge";
|
||||
import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { deleteMCPServer } from "../networking";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPServerHealth } from "@/app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { deleteMCPServer } from "@/components/networking";
|
||||
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
|
||||
import { MCPToolsetsTab } from "./MCPToolsetsTab";
|
||||
import CreateMCPServer from "./create_mcp_server";
|
||||
import MCPConnect from "./mcp_connect";
|
||||
import MCPServerCard from "./MCPServerCard";
|
||||
import { MCPServerView } from "./mcp_server_view";
|
||||
import type { DiscoverableMCPServer, MCPServer, MCPServerProps, MCPUserEnvVarsStatus, Team } from "./types";
|
||||
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
|
||||
import type {
|
||||
DiscoverableMCPServer,
|
||||
MCPServer,
|
||||
MCPServerProps,
|
||||
MCPUserEnvVarsStatus,
|
||||
Team,
|
||||
} from "@/components/mcp_tools/types";
|
||||
import MCPSemanticFilterSettings from "@/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
|
||||
import MCPNetworkSettings from "./MCPNetworkSettings";
|
||||
import MCPDiscovery from "./mcp_discovery";
|
||||
import { ByokCredentialModal } from "./ByokCredentialModal";
|
||||
import { ByokCredentialModal } from "@/components/mcp_tools/ByokCredentialModal";
|
||||
import { getSecureItem } from "@/utils/secureStorage";
|
||||
import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils";
|
||||
import UserEnvVarsModal from "./UserEnvVarsModal";
|
||||
import { listMCPUserEnvVarStatus } from "../networking";
|
||||
import { listMCPUserEnvVarStatus } from "@/components/networking";
|
||||
|
||||
type SortKey = "created_desc" | "updated_desc" | "name_asc" | "health";
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
|
||||
import McpCrudPermissionPanel from "./McpCrudPermissionPanel";
|
||||
import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel";
|
||||
import { TOOL_DISPLAY_NAME_PATTERN } from "./utils";
|
||||
|
||||
interface KeyTool {
|
||||
|
|
@ -2,10 +2,10 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import MCPToolsViewer from "./mcp_tools";
|
||||
import { listMCPTools, getMCPOAuthUserCredentialStatus } from "../networking";
|
||||
import { listMCPTools, getMCPOAuthUserCredentialStatus } from "@/components/networking";
|
||||
import { isTokenValid, getToken } from "@/utils/mcpTokenStore";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
vi.mock("@/components/networking", () => ({
|
||||
listMCPTools: vi.fn(),
|
||||
callMCPTool: vi.fn(),
|
||||
getMCPOAuthUserCredentialStatus: vi.fn(),
|
||||
|
|
@ -9,8 +9,8 @@ import {
|
|||
MCPContent,
|
||||
CallMCPToolResponse,
|
||||
getMcpOAuthMode,
|
||||
} from "./types";
|
||||
import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "../networking";
|
||||
} from "@/components/mcp_tools/types";
|
||||
import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "@/components/networking";
|
||||
import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore";
|
||||
import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
|
||||
import { useToolsOAuthFlow } from "@/hooks/useToolsOAuthFlow";
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { MCPEnvVar, MCPEnvVarScope } from "./types";
|
||||
import { MCPEnvVar, MCPEnvVarScope } from "@/components/mcp_tools/types";
|
||||
|
||||
export const extractMCPToken = (url: string): { token: string | null; baseUrl: string } => {
|
||||
try {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MCPServers } from "@/components/mcp_tools";
|
||||
import { MCPServers } from "./_components";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function McpServers() {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const EndpointUsage: React.FC<EndpointUsageProps> = ({ userSpendData }) => {
|
|||
<div className="space-y-4">
|
||||
<EndpointUsageTable endpointData={endpointData} />
|
||||
<EndpointUsageBarChart endpointData={endpointData} />
|
||||
<EndpointUsageLineChart dailyData={userSpendData} endpointData={endpointData} />
|
||||
<EndpointUsageLineChart dailyData={userSpendData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,39 +1,70 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
import EndpointUsageBarChart from "./EndpointUsageBarChart";
|
||||
|
||||
vi.mock("@tremor/react", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function Card({ children }: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-card" }, children);
|
||||
}
|
||||
(Card as any).displayName = "Card";
|
||||
|
||||
function Title({ children }: any) {
|
||||
return React.createElement("h2", { "data-testid": "tremor-title" }, children);
|
||||
}
|
||||
(Title as any).displayName = "Title";
|
||||
|
||||
function BarChart(_props: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-bar-chart" }, "Bar Chart");
|
||||
}
|
||||
(BarChart as any).displayName = "BarChart";
|
||||
|
||||
return { Card, Title, BarChart };
|
||||
const metric = (successful: number, failed: number): MetricWithMetadata => ({
|
||||
metrics: {
|
||||
spend: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: successful + failed,
|
||||
successful_requests: successful,
|
||||
failed_requests: failed,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
metadata: {},
|
||||
api_key_breakdown: {},
|
||||
});
|
||||
|
||||
vi.mock("@/components/common_components/chartUtils", () => ({
|
||||
CustomLegend: ({ categories }: any) => <div data-testid="custom-legend">{categories.join(", ")}</div>,
|
||||
CustomTooltip: () => <div data-testid="custom-tooltip">Tooltip</div>,
|
||||
}));
|
||||
const endpointData = {
|
||||
"/chat/completions": metric(120, 5),
|
||||
"/embeddings": metric(40, 2),
|
||||
};
|
||||
|
||||
describe("EndpointUsageBarChart", () => {
|
||||
it("should render", () => {
|
||||
render(<EndpointUsageBarChart />);
|
||||
it("renders the title and the header legend labels", () => {
|
||||
renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
|
||||
|
||||
expect(screen.getByTestId("tremor-card")).toBeInTheDocument();
|
||||
expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tremor-bar-chart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Successful Requests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed Requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders stacked green and red bars per endpoint", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
|
||||
|
||||
expect(container.querySelectorAll(".recharts-bar")).toHaveLength(2);
|
||||
const rectangles = Array.from(container.querySelectorAll("path.recharts-rectangle"));
|
||||
expect(rectangles).toHaveLength(4);
|
||||
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
|
||||
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
|
||||
|
||||
const xPositions = rectangles.map((rect) => rect.getAttribute("d")?.split(",")[0]);
|
||||
expect(new Set(xPositions).size).toBe(2);
|
||||
});
|
||||
|
||||
it("labels the x axis with endpoint names", () => {
|
||||
renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
|
||||
|
||||
expect(screen.getAllByText("/chat/completions").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("/embeddings").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the chart's own legend off; only the header legend is shown", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
|
||||
|
||||
expect(container.querySelector(".recharts-legend-wrapper")).toBeNull();
|
||||
expect(screen.queryByText("metrics.successful_requests")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty chart without bars when endpointData is absent", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageBarChart />);
|
||||
|
||||
expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { BarChart, Card, Title } from "@tremor/react";
|
||||
import { CustomLegend, CustomTooltip } from "@/components/common_components/chartUtils";
|
||||
import { BarChart, CustomLegend, CustomTooltip } from "@/components/shared/charts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
|
||||
interface EndpointUsageBarChartProps {
|
||||
|
|
@ -8,11 +8,9 @@ interface EndpointUsageBarChartProps {
|
|||
}
|
||||
|
||||
const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointData }) => {
|
||||
const dataToUse = endpointData || {};
|
||||
|
||||
// Transform endpoint data into chart format
|
||||
const chartData = React.useMemo(() => {
|
||||
return Object.entries(dataToUse).map(([endpoint, data]) => ({
|
||||
return Object.entries(endpointData || {}).map(([endpoint, data]) => ({
|
||||
endpoint,
|
||||
"metrics.successful_requests": data.metrics.successful_requests,
|
||||
"metrics.failed_requests": data.metrics.failed_requests,
|
||||
|
|
@ -21,31 +19,34 @@ const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointD
|
|||
failed_requests: data.metrics.failed_requests,
|
||||
},
|
||||
}));
|
||||
}, [dataToUse]);
|
||||
}, [endpointData]);
|
||||
|
||||
const valueFormatter = (value: number) => value.toLocaleString();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Success vs Failed Requests by Endpoint</Title>
|
||||
<CustomLegend
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-base font-semibold">Success vs Failed Requests by Endpoint</CardTitle>
|
||||
<CustomLegend
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="endpoint"
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
stack={true}
|
||||
yAxisWidth={60}
|
||||
/>
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={chartData}
|
||||
index="endpoint"
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
stack={true}
|
||||
yAxisWidth={60}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,34 +1,103 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import EndpointUsageLineChart from "./EndpointUsageLineChart";
|
||||
|
||||
vi.mock("@tremor/react", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function Card({ children }: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-card" }, children);
|
||||
}
|
||||
(Card as any).displayName = "Card";
|
||||
|
||||
function Title({ children }: any) {
|
||||
return React.createElement("h2", { "data-testid": "tremor-title" }, children);
|
||||
}
|
||||
(Title as any).displayName = "Title";
|
||||
|
||||
function LineChart(_props: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-line-chart" }, "Line Chart");
|
||||
}
|
||||
(LineChart as any).displayName = "LineChart";
|
||||
|
||||
return { Card, Title, LineChart };
|
||||
const spendMetrics = (apiRequests: number): SpendMetrics => ({
|
||||
spend: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: apiRequests,
|
||||
successful_requests: apiRequests,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
|
||||
const endpointMetric = (apiRequests: number): MetricWithMetadata => ({
|
||||
metrics: spendMetrics(apiRequests),
|
||||
metadata: {},
|
||||
api_key_breakdown: {},
|
||||
});
|
||||
|
||||
const day = (date: string, endpoints: Record<string, number>): DailyData => ({
|
||||
date,
|
||||
metrics: spendMetrics(0),
|
||||
breakdown: {
|
||||
models: {},
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: {},
|
||||
entities: {},
|
||||
endpoints: Object.fromEntries(
|
||||
Object.entries(endpoints).map(([name, requests]) => [name, endpointMetric(requests)]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const dailyData = {
|
||||
results: [
|
||||
day("2026-06-03T12:00:00", { "/chat/completions": 4000, "/embeddings": 900 }),
|
||||
day("2026-06-02T12:00:00", { "/chat/completions": 2500, "/embeddings": 700 }),
|
||||
day("2026-06-01T12:00:00", { "/chat/completions": 1200 }),
|
||||
],
|
||||
};
|
||||
|
||||
describe("EndpointUsageLineChart", () => {
|
||||
it("should render", () => {
|
||||
render(<EndpointUsageLineChart />);
|
||||
it("renders the title", () => {
|
||||
renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
|
||||
|
||||
expect(screen.getByTestId("tremor-card")).toBeInTheDocument();
|
||||
expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tremor-line-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one line per endpoint with the tremor palette strokes", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
|
||||
|
||||
const curves = Array.from(container.querySelectorAll("path.recharts-line-curve"));
|
||||
expect(curves).toHaveLength(2);
|
||||
expect(new Set(curves.map((curve) => curve.getAttribute("stroke")))).toEqual(
|
||||
new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a legend with the endpoint names", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
|
||||
|
||||
const legend = container.querySelector(".recharts-legend-wrapper");
|
||||
expect(legend).not.toBeNull();
|
||||
expect(legend!.textContent).toContain("/chat/completions");
|
||||
expect(legend!.textContent).toContain("/embeddings");
|
||||
});
|
||||
|
||||
it("orders formatted dates oldest to newest on the x axis", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
|
||||
|
||||
const tickLabels = Array.from(container.querySelectorAll(".recharts-xAxis-tick-labels text")).map(
|
||||
(tick) => tick.textContent,
|
||||
);
|
||||
expect(tickLabels).toEqual(["Jun 1", "Jun 2", "Jun 3"]);
|
||||
});
|
||||
|
||||
it("formats y axis ticks with toLocaleString", () => {
|
||||
renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
|
||||
|
||||
expect(screen.getAllByText(/^\d,\d{3}$/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("draws smooth natural curves", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
|
||||
|
||||
const path = container.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
|
||||
expect(path).toContain("C");
|
||||
});
|
||||
|
||||
it("renders an empty chart without lines when dailyData is absent", () => {
|
||||
const { container } = renderWithProviders(<EndpointUsageLineChart />);
|
||||
|
||||
expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("path.recharts-line-curve")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Card, LineChart, Title } from "@tremor/react";
|
||||
import { useMemo } from "react";
|
||||
import { LineChart, type ChartColor } from "@/components/shared/charts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DailyData } from "@/components/UsagePage/types";
|
||||
|
||||
interface EndpointUsageLineChartProps {
|
||||
dailyData?: { results: DailyData[] };
|
||||
endpointData?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Transform daily data into chart format
|
||||
|
|
@ -42,7 +42,7 @@ function transformDailyDataToChart(dailyData: DailyData[]): Array<Record<string,
|
|||
return chartData.reverse();
|
||||
}
|
||||
|
||||
export function EndpointUsageLineChart({ dailyData, endpointData }: EndpointUsageLineChartProps) {
|
||||
export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!dailyData?.results || dailyData.results.length === 0) {
|
||||
return [];
|
||||
|
|
@ -59,26 +59,39 @@ export function EndpointUsageLineChart({ dailyData, endpointData }: EndpointUsag
|
|||
}, [chartData]);
|
||||
|
||||
// Tremor color palette for multiple lines
|
||||
const colors = ["blue", "cyan", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "red", "orange"];
|
||||
const colors: readonly ChartColor[] = [
|
||||
"blue",
|
||||
"cyan",
|
||||
"indigo",
|
||||
"violet",
|
||||
"purple",
|
||||
"fuchsia",
|
||||
"pink",
|
||||
"rose",
|
||||
"red",
|
||||
"orange",
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Title>Endpoint Usage Trends</Title>
|
||||
</div>
|
||||
<LineChart
|
||||
className="h-80"
|
||||
data={chartData}
|
||||
index="date"
|
||||
categories={categories}
|
||||
colors={colors.slice(0, categories.length)}
|
||||
valueFormatter={(value) => value.toLocaleString()}
|
||||
showLegend={true}
|
||||
showGridLines={true}
|
||||
yAxisWidth={60}
|
||||
connectNulls={true}
|
||||
curveType="natural"
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Endpoint Usage Trends</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LineChart
|
||||
className="h-80"
|
||||
data={chartData}
|
||||
index="date"
|
||||
categories={categories}
|
||||
colors={colors.slice(0, categories.length)}
|
||||
valueFormatter={(value) => value.toLocaleString()}
|
||||
showLegend={true}
|
||||
showGridLines={true}
|
||||
yAxisWidth={60}
|
||||
connectNulls={true}
|
||||
curveType="natural"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ export {
|
|||
} from "./chart_tooltip";
|
||||
export { CHART_COLOR_HEX, DEFAULT_COLOR_CYCLE, categoryFills, chartColorValue, type ChartColor } from "./colors";
|
||||
export { DonutChart, type DonutChartProps } from "./donut_chart";
|
||||
export { LineChart, type LineChartCurveType, type LineChartProps } from "./line_chart";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LineChart } from "./line_chart";
|
||||
|
||||
const data = [
|
||||
{ date: "Jun 1", "/chat/completions": 10, "/embeddings": 4 },
|
||||
{ date: "Jun 2", "/chat/completions": 15, "/embeddings": 6 },
|
||||
{ date: "Jun 3", "/chat/completions": 12, "/embeddings": 9 },
|
||||
];
|
||||
|
||||
describe("LineChart", () => {
|
||||
it("renders one line per category with the mapped tremor stroke colors", () => {
|
||||
const { container } = render(
|
||||
<LineChart
|
||||
data={data}
|
||||
index="date"
|
||||
categories={["/chat/completions", "/embeddings"]}
|
||||
colors={["blue", "cyan"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const curves = Array.from(container.querySelectorAll("path.recharts-line-curve"));
|
||||
expect(curves).toHaveLength(2);
|
||||
expect(curves.map((curve) => curve.getAttribute("stroke"))).toEqual([
|
||||
"var(--color-blue-500, #3b82f6)",
|
||||
"var(--color-cyan-500, #06b6d4)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the tremor default color cycle when no colors are passed", () => {
|
||||
const { container } = render(
|
||||
<LineChart data={data} index="date" categories={["/chat/completions", "/embeddings"]} />,
|
||||
);
|
||||
|
||||
const strokes = Array.from(container.querySelectorAll("path.recharts-line-curve")).map((curve) =>
|
||||
curve.getAttribute("stroke"),
|
||||
);
|
||||
expect(strokes).toEqual(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]);
|
||||
});
|
||||
|
||||
it("applies valueFormatter to the value axis ticks", () => {
|
||||
render(
|
||||
<LineChart
|
||||
data={data}
|
||||
index="date"
|
||||
categories={["/chat/completions"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={(v) => `${v} req`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/ req$/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a legend by default, matching tremor, and hides it when showLegend is false", () => {
|
||||
const { container, rerender } = render(
|
||||
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
|
||||
);
|
||||
expect(screen.getByText("/chat/completions")).toBeInTheDocument();
|
||||
expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} showLegend={false} />,
|
||||
);
|
||||
expect(screen.queryByText("/chat/completions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("draws straight segments by default and curved segments for curveType natural", () => {
|
||||
const { container: linear } = render(
|
||||
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
|
||||
);
|
||||
const { container: natural } = render(
|
||||
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} curveType="natural" />,
|
||||
);
|
||||
|
||||
const linearPath = linear.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
|
||||
const naturalPath = natural.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
|
||||
expect(linearPath).not.toContain("C");
|
||||
expect(naturalPath).toContain("C");
|
||||
});
|
||||
|
||||
it("bridges gaps over null values only when connectNulls is set", () => {
|
||||
const gappedData = [
|
||||
{ date: "Jun 1", "/chat/completions": 10 },
|
||||
{ date: "Jun 2", "/chat/completions": null },
|
||||
{ date: "Jun 3", "/chat/completions": 12 },
|
||||
{ date: "Jun 4", "/chat/completions": 15 },
|
||||
];
|
||||
|
||||
const { container: broken } = render(
|
||||
<LineChart data={gappedData} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
|
||||
);
|
||||
const { container: bridged } = render(
|
||||
<LineChart data={gappedData} index="date" categories={["/chat/completions"]} colors={["blue"]} connectNulls />,
|
||||
);
|
||||
|
||||
const brokenPath = broken.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
|
||||
const bridgedPath = bridged.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
|
||||
expect((brokenPath.match(/M/g) ?? []).length).toBeGreaterThan(1);
|
||||
expect((bridgedPath.match(/M/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders an empty chart without lines when there are no categories", () => {
|
||||
const { container } = render(<LineChart data={[]} index="date" categories={[]} />);
|
||||
|
||||
expect(container.querySelector("[data-slot='chart']")).not.toBeNull();
|
||||
expect(container.querySelectorAll("path.recharts-line-curve")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits no per-chart style tag; colors flow through strokes, not CSS vars", () => {
|
||||
const { container } = render(
|
||||
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
|
||||
);
|
||||
expect(container.querySelector("style")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CartesianGrid, Line, LineChart as RechartsLineChart, XAxis, YAxis } from "recharts";
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip";
|
||||
import { categoryFills, type ChartColor } from "./colors";
|
||||
|
||||
export type LineChartCurveType = "linear" | "natural" | "monotone" | "step";
|
||||
|
||||
export type LineChartProps<TDatum extends Record<string, unknown>> = {
|
||||
data: readonly TDatum[];
|
||||
index: string;
|
||||
categories: readonly string[];
|
||||
colors?: readonly ChartColor[];
|
||||
valueFormatter?: (value: number) => string;
|
||||
yAxisWidth?: number;
|
||||
tickGap?: number;
|
||||
showLegend?: boolean;
|
||||
showXAxis?: boolean;
|
||||
showGridLines?: boolean;
|
||||
showTooltip?: boolean;
|
||||
customTooltip?: ChartTooltipComponent;
|
||||
connectNulls?: boolean;
|
||||
curveType?: LineChartCurveType;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export function LineChart<TDatum extends Record<string, unknown>>({
|
||||
data,
|
||||
index,
|
||||
categories,
|
||||
colors,
|
||||
valueFormatter,
|
||||
yAxisWidth = 56,
|
||||
tickGap = 5,
|
||||
showLegend = true,
|
||||
showXAxis = true,
|
||||
showGridLines = true,
|
||||
showTooltip = true,
|
||||
customTooltip,
|
||||
connectNulls = false,
|
||||
curveType = "linear",
|
||||
className,
|
||||
style,
|
||||
}: LineChartProps<TDatum>) {
|
||||
const fills = categoryFills(categories.length, colors);
|
||||
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
|
||||
const TooltipContent = customTooltip ?? ValueTooltip;
|
||||
|
||||
return (
|
||||
<ChartContainer config={config} className={cn("aspect-auto h-80 w-full", className)} style={style}>
|
||||
<RechartsLineChart data={[...data]}>
|
||||
{showGridLines && <CartesianGrid vertical={false} />}
|
||||
<XAxis
|
||||
dataKey={index}
|
||||
hide={!showXAxis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={tickGap}
|
||||
interval="equidistantPreserveStart"
|
||||
/>
|
||||
<YAxis width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={valueFormatter} />
|
||||
{showTooltip && (
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<TooltipContent
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label}
|
||||
{...(customTooltip ? {} : { valueFormatter })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showLegend && (
|
||||
<ChartLegend
|
||||
verticalAlign="top"
|
||||
content={<ChartLegendContent className="justify-end text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{categories.map((category, i) => (
|
||||
<Line
|
||||
key={category}
|
||||
type={curveType}
|
||||
dataKey={category}
|
||||
stroke={fills[i]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={connectNulls}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import createFetchClient, { type Middleware } from "openapi-fetch";
|
||||
import createQueryClient from "openapi-react-query";
|
||||
import type { paths } from "./schema";
|
||||
import { ApiError, deriveErrorMessage } from "./client";
|
||||
import { getAuthHeaderName, getAuthToken, getRequestBaseUrl, reportError } from "./runtime";
|
||||
|
|
@ -46,3 +47,11 @@ const middleware: Middleware = {
|
|||
*/
|
||||
export const fetchClient = createFetchClient<paths>({ baseUrl: globalThis.location?.origin ?? "" });
|
||||
fetchClient.use(middleware);
|
||||
|
||||
/**
|
||||
* TanStack Query bound to the typed client. Callers write
|
||||
* `$api.useQuery("get", "/path", init, options)`; the query key is derived from
|
||||
* method + path + init (no hand-maintained key), the request signal is
|
||||
* forwarded for cancellation, and the response type comes from schema.d.ts.
|
||||
*/
|
||||
export const $api = createQueryClient(fetchClient);
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard"
|
|||
vi.mock("@/app/(dashboard)/guardrails/_components", () => ({ default: stub("guardrails") }));
|
||||
vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
|
||||
vi.mock("@/components/transform_request", () => ({ default: stub("transform-request") }));
|
||||
vi.mock("@/components/mcp_tools", () => ({ MCPServers: stub("mcp-servers") }));
|
||||
vi.mock("@/app/(dashboard)/mcp-servers/_components", () => ({ MCPServers: stub("mcp-servers") }));
|
||||
vi.mock("@/app/(dashboard)/tag-management/_components", () => ({ default: stub("tag-management") }));
|
||||
vi.mock("@/app/(dashboard)/vector-stores/_components", () => ({ default: stub("vector-stores") }));
|
||||
vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue