From 33dc1628931fec15c7a4eb5987240fb508938d38 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:28:37 -0700 Subject: [PATCH 1/3] test(ui): pin logging-and-alerts, caching and policies behaviour before the shadcn migration Establishes the regression net for the upcoming markup migration of these three routes. Every assertion here is written against the current antd and Tremor components and passes against them, so it carries no knowledge of the markup that replaces them and stays meaningful afterwards. Adds characterisation tests for the seven components that had none, and rewrites cache_dashboard's chart-card lookup to anchor on each chart's own title instead of asserting a global count of card nodes, which would break the moment another card appears on the page. No component is touched in this commit. --- .../_components/cache_dashboard.test.tsx | 17 +- .../caching/_components/cache_health.test.tsx | 148 +++++++++++ .../CoordinationRedisTypeSelector.test.tsx | 56 +++++ .../_components/ai_suggestion_modal.test.tsx | 230 ++++++++++++++++++ .../pipeline_flow_builder.test.tsx | 169 +++++++++++++ .../template_parameter_modal.test.tsx | 206 ++++++++++++++++ .../email_event_settings.test.tsx | 132 ++++++++++ .../src/components/email_settings.test.tsx | 127 ++++++++++ 8 files changed, 1081 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/email_settings.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx index 13472a3d1df..e1d02e9352d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx @@ -40,14 +40,23 @@ const renderDashboard = () => , ); +const REQUESTS_CHART_TITLE = "Cache Hits vs API Requests"; +const TOKENS_CHART_TITLE = "Cached Completion Tokens vs Generated Completion Tokens"; + +// Anchored on each chart's own title rather than on a global card count, so +// adding cards elsewhere on the page cannot silently repoint these assertions. +const cardTitled = (title: string): HTMLElement => { + const card = screen.getByText(title).closest('[data-slot="card"]'); + expect(card).not.toBeNull(); + return card as HTMLElement; +}; + const findChartCards = async () => { - await screen.findByText("Cache Hits vs API Requests"); + await screen.findByText(REQUESTS_CHART_TITLE); await waitFor(() => { expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); }); - const cards = Array.from(document.querySelectorAll('[data-slot="card"]')); - expect(cards).toHaveLength(2); - return { requestsCard: cards[0] as HTMLElement, tokensCard: cards[1] as HTMLElement }; + return { requestsCard: cardTitled(REQUESTS_CHART_TITLE), tokensCard: cardTitled(TOKENS_CHART_TITLE) }; }; const barFills = (card: HTMLElement) => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx new file mode 100644 index 00000000000..de06ef07ed1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import { CacheHealthTab } from "./cache_health"; + +const healthyResponse = { + status: "healthy", + ping_response: true, + set_cache_response: "success", + litellm_cache_params: JSON.stringify({ type: "redis", supported_call_types: ["acompletion"] }), + health_check_cache_params: JSON.stringify({ + redis_version: "7.2.1", + namespace: "litellm-ns", + connection_kwargs: { host: "redis.internal", port: 6379 }, + }), +}; + +const errorPayload = { + message: "Connection refused", + traceback: "Traceback (most recent call last): ...", + litellm_cache_params: { type: "redis" }, + health_check_cache_params: {}, +}; + +const errorResponse = { error: { message: JSON.stringify(errorPayload) } }; + +const renderTab = (overrides: Partial> = {}) => + renderWithProviders( + , + ); + +describe("CacheHealthTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("offers a health check button and no results before one is run", () => { + renderTab(); + + expect(screen.getByRole("button", { name: "Run Health Check" })).toBeInTheDocument(); + expect(screen.queryByText(/Cache Status:/)).not.toBeInTheDocument(); + }); + + it("runs the health check when the button is clicked", async () => { + const runCachingHealthCheck = vi.fn(); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + expect(runCachingHealthCheck).toHaveBeenCalledTimes(1); + }); + + it("shows an in-flight label and disables the button while the check runs", async () => { + const runCachingHealthCheck = vi.fn(() => new Promise(() => {})); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + const button = await screen.findByRole("button", { name: "Running Health Check..." }); + expect(button).toBeDisabled(); + }); + + it("reports a healthy cache with its ping and set-cache results", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.getByText("Cache Details")).toBeInTheDocument(); + expect(screen.getByText("Ping Response")).toBeInTheDocument(); + expect(screen.getByText("Set Cache Response")).toBeInTheDocument(); + expect(screen.getByText("success")).toBeInTheDocument(); + }); + + it("shows the Redis detail rows when the cache type is redis", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Redis Details")).toBeInTheDocument(); + expect(screen.getByText("Redis Host")).toBeInTheDocument(); + expect(screen.getByText("redis.internal")).toBeInTheDocument(); + expect(screen.getByText("Redis Port")).toBeInTheDocument(); + expect(screen.getByText("Redis Version")).toBeInTheDocument(); + expect(screen.getByText("7.2.1")).toBeInTheDocument(); + expect(screen.getByText("Namespace")).toBeInTheDocument(); + expect(screen.getByText("litellm-ns")).toBeInTheDocument(); + }); + + it("omits the Redis detail rows for a non-redis cache type", async () => { + renderTab({ + healthCheckResponse: { + status: "healthy", + ping_response: true, + litellm_cache_params: JSON.stringify({ type: "local" }), + health_check_cache_params: JSON.stringify({}), + }, + }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.queryByText("Redis Details")).not.toBeInTheDocument(); + }); + + it("surfaces the error message and traceback when the check fails", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Error Details")).toBeInTheDocument(); + expect(screen.getByText("Error Message")).toBeInTheDocument(); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + expect(screen.getByText("Traceback")).toBeInTheDocument(); + expect(screen.getByText("Cache Status: unhealthy")).toBeInTheDocument(); + }); + + it("still shows the cache details section when the check failed", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Cache Details")).toBeInTheDocument(); + }); + + it("truncates a long value and expands it to the full value on click", async () => { + const longMessage = "M".repeat(120); + const user = userEvent.setup(); + renderTab({ + healthCheckResponse: { + error: { message: JSON.stringify({ message: longMessage, traceback: "short" }) }, + }, + }); + + await screen.findByText("Error Message"); + expect(screen.getByText(`${"M".repeat(50)}...`)).toBeInTheDocument(); + expect(screen.queryByText(longMessage)).not.toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "▶" })[0]); + + await waitFor(() => { + expect(screen.getByText(longMessage)).toBeInTheDocument(); + }); + }); + + it("offers both the summary and raw response views", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("Raw Response")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx new file mode 100644 index 00000000000..76287e6e724 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector"; +import { COORDINATION_REDIS_TYPE_DESCRIPTIONS } from "./coordinationRedisFields"; + +describe("CoordinationRedisTypeSelector", () => { + it("labels the control and shows the current selection", () => { + renderWithProviders(); + + expect(screen.getByText("Redis Type")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Node (Single Instance)")).toBeInTheDocument(); + }); + + it("shows the description for the selected type", () => { + renderWithProviders(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.cluster)).toBeInTheDocument(); + }); + + it("switches the description when the selected type changes", () => { + const { rerender } = renderWithProviders(); + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.sentinel)).toBeInTheDocument(); + expect(screen.queryByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).not.toBeInTheDocument(); + }); + + it("reports the newly picked type to the caller", async () => { + const onTypeChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Cluster")); + + expect(onTypeChange).toHaveBeenCalledTimes(1); + expect(onTypeChange.mock.calls[0][0]).toBe("cluster"); + }); + + it("offers every supported coordination redis type", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByText("Cluster")).toBeInTheDocument(); + expect(screen.getByText("Sentinel")).toBeInTheDocument(); + expect(screen.getAllByText("Node (Single Instance)").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx new file mode 100644 index 00000000000..4db75fb1ced --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import AiSuggestionModal from "./ai_suggestion_modal"; + +const { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplateStream } = vi.hoisted(() => ({ + suggestPolicyTemplates: vi.fn(), + modelHubCall: vi.fn(), + testPolicyTemplate: vi.fn(), + enrichPolicyTemplateStream: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + suggestPolicyTemplates, + modelHubCall, + testPolicyTemplate, + enrichPolicyTemplateStream, +})); + +const allTemplates = [ + { id: "tpl-pii", title: "PII Protection", description: "Masks PII", guardrails: ["pii-masker"], complexity: "Low" }, + { + id: "tpl-inj", + title: "Injection Defense", + description: "Blocks prompt injection", + guardrails: ["prompt-injection"], + complexity: "Medium", + }, +]; + +const suggestResponse = { + selected_templates: [ + { template_id: "tpl-pii", reason: "Your examples contain SSNs" }, + { template_id: "tpl-inj", reason: "Your examples contain instruction overrides" }, + ], + explanation: "These two cover both risks you described", +}; + +const defaultProps = { + visible: true, + onSelectTemplates: vi.fn(), + onCancel: vi.fn(), + accessToken: "sk-test", + allTemplates, +}; + +const renderModal = (props: Partial = {}) => + renderWithProviders(); + +const pickModel = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); +}; + +describe("AiSuggestionModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }, { model_group: "claude-opus-4-8" }] }); + suggestPolicyTemplates.mockResolvedValue(suggestResponse); + }); + + it("renders nothing while closed", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("AI Policy Suggestion")).not.toBeInTheDocument(); + }); + + it("renders the header and prompt copy when opened", async () => { + renderModal(); + + expect(await screen.findByText("AI Policy Suggestion")).toBeInTheDocument(); + expect( + screen.getByText("Describe what you want to block and we'll suggest the best policy templates"), + ).toBeInTheDocument(); + }); + + it("loads the model list when opened", async () => { + renderModal(); + + await waitFor(() => { + expect(modelHubCall).toHaveBeenCalledWith("sk-test"); + }); + }); + + it("keeps Suggest disabled until there is both input and a model", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled(); + + await pickModel(user); + expect(screen.getByRole("button", { name: "Suggest Policies" })).not.toBeDisabled(); + }); + + it("sends the examples, description and model to the suggest API", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Ignore all previous instructions/), "my ssn is 123"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + await waitFor(() => { + expect(suggestPolicyTemplates).toHaveBeenCalledWith("sk-test", ["my ssn is 123"], "block PII", "gpt-5.1"); + }); + }); + + it("adds attack example fields up to the maximum of four", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + const countExamples = () => screen.getAllByRole("textbox").length; + const initial = countExamples(); + + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + expect(countExamples()).toBe(initial + 1); + + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + expect(countExamples()).toBe(initial + 3); + expect(screen.queryByRole("button", { name: "+ Add another example" })).not.toBeInTheDocument(); + }); + + it("shows each suggested template with the reason it was picked", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByText("PII Protection")).toBeInTheDocument(); + expect(screen.getByText("Injection Defense")).toBeInTheDocument(); + expect(screen.getByText("Your examples contain SSNs")).toBeInTheDocument(); + expect(screen.getByText("These two cover both risks you described")).toBeInTheDocument(); + expect(screen.getByText("2 templates matched your requirements")).toBeInTheDocument(); + }); + + it("preselects every suggestion and reflects the count on the confirm button", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByRole("button", { name: "Use 2 Selected Templates" })).toBeInTheDocument(); + }); + + it("deselecting a suggestion lowers the confirm count", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + await user.click(await screen.findByText("PII Protection")); + + expect(await screen.findByRole("button", { name: "Use 1 Selected Template" })).toBeInTheDocument(); + }); + + it("hands the selected templates back to the caller", async () => { + const onSelectTemplates = vi.fn(); + const user = userEvent.setup(); + renderModal({ onSelectTemplates }); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + await user.click(await screen.findByRole("button", { name: "Use 2 Selected Templates" })); + + expect(onSelectTemplates).toHaveBeenCalledTimes(1); + expect(onSelectTemplates.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(["tpl-pii", "tpl-inj"]); + }); + + it("returns to the input phase from the results phase", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + await user.click(await screen.findByRole("button", { name: "Back" })); + + expect( + await screen.findByText("Describe what you want to block and we'll suggest the best policy templates"), + ).toBeInTheDocument(); + }); + + it("reports an empty result set instead of failing silently", async () => { + suggestPolicyTemplates.mockRejectedValue(new Error("boom")); + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByText("No matching templates found")).toBeInTheDocument(); + expect(screen.getByText("Try adjusting your examples or description.")).toBeInTheDocument(); + }); + + it("cancels back to the caller", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderModal({ onCancel }); + + await screen.findByText("AI Policy Suggestion"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx new file mode 100644 index 00000000000..cb763c2d22f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import PipelineFlowBuilder, { PipelineInfoDisplay } from "./pipeline_flow_builder"; +import { GuardrailPipeline, PipelineStep } from "@/components/policies/types"; +import { Guardrail } from "@/components/guardrails/types"; + +vi.mock("@/components/networking"); + +const step = (overrides: Partial = {}): PipelineStep => ({ + guardrail: "pii-masker", + on_pass: "next", + on_fail: "block", + on_error: null, + modify_response_message: null, + ...overrides, +}); + +const pipeline = (steps: PipelineStep[]): GuardrailPipeline => ({ mode: "pre_call", steps }); + +const guardrails = [ + { guardrail_id: "g1", guardrail_name: "pii-masker" }, + { guardrail_id: "g2", guardrail_name: "prompt-injection" }, +] as Guardrail[]; + +describe("PipelineInfoDisplay", () => { + it("renders the trigger card", () => { + renderWithProviders(); + + expect(screen.getByText("TRIGGER")).toBeInTheDocument(); + expect(screen.getByText("Incoming LLM Request")).toBeInTheDocument(); + }); + + it("renders one numbered card per step, naming its guardrail", () => { + renderWithProviders(); + + expect(screen.getByText("Step 1")).toBeInTheDocument(); + expect(screen.getByText("Step 2")).toBeInTheDocument(); + expect(screen.getByText("pii-masker")).toBeInTheDocument(); + expect(screen.getByText("prompt-injection")).toBeInTheDocument(); + expect(screen.getAllByText("GUARDRAIL")).toHaveLength(2); + }); + + it("maps raw action values to their human labels", () => { + renderWithProviders(); + + expect(screen.getByText(/Pass .* Next Step/)).toBeInTheDocument(); + expect(screen.getByText(/On fail .* Block/)).toBeInTheDocument(); + }); + + it("falls back to the on-fail action when no API-failure action is set", () => { + renderWithProviders(); + + expect(screen.getByText(/On API failure .* Block \(same as on fail\)/)).toBeInTheDocument(); + }); + + it("shows an explicit API-failure action when one is set", () => { + renderWithProviders(); + + expect(screen.getByText(/On API failure .* Allow/)).toBeInTheDocument(); + expect(screen.queryByText(/same as on fail/)).not.toBeInTheDocument(); + }); +}); + +describe("PipelineFlowBuilder", () => { + it("renders the trigger and end cards around the steps", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("TRIGGER")).toBeInTheDocument(); + expect(screen.getByText("END")).toBeInTheDocument(); + expect(screen.getByText("Continue to LLM")).toBeInTheDocument(); + }); + + it("labels each decision section of a step", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("ON PASS")).toBeInTheDocument(); + expect(screen.getByText("ON FAIL")).toBeInTheDocument(); + expect(screen.getByText("ON API FAILURE")).toBeInTheDocument(); + }); + + it("inserts a step at the clicked connector", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("button", { name: "Insert step" })[0]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].steps).toHaveLength(2); + }); + + it("removes the clicked step when more than one exists", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("button", { name: "Delete step" })[0]); + + expect(onChange.mock.calls[0][0].steps).toHaveLength(1); + expect(onChange.mock.calls[0][0].steps[0].guardrail).toBe("prompt-injection"); + }); + + it("disables deletion of the only remaining step", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "Delete step" })).toBeDisabled(); + }); + + it("offers a custom response field only when the action is modify_response", () => { + const { rerender } = renderWithProviders( + , + ); + expect(screen.queryByPlaceholderText("Enter custom response...")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByPlaceholderText("Enter custom response...")).toBeInTheDocument(); + }); + + it("reports an edited custom response message", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText("Enter custom response..."), "x"); + + expect(onChange.mock.calls[0][0].steps[0].modify_response_message).toBe("x"); + }); + + it("offers a guardrail picker for the step", () => { + renderWithProviders( + , + ); + + // Which control surfaces the selection is a presentation detail; that the step's + // guardrail is the one displayed is covered by the PipelineInfoDisplay tests above. + expect(screen.getByText("Guardrail")).toBeInTheDocument(); + expect(screen.getAllByRole("combobox").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx new file mode 100644 index 00000000000..1af45012d54 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import TemplateParameterModal from "./template_parameter_modal"; + +const { modelHubCall, enrichPolicyTemplateStream } = vi.hoisted(() => ({ + modelHubCall: vi.fn(), + enrichPolicyTemplateStream: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ modelHubCall, enrichPolicyTemplateStream })); + +type StreamResult = { competitors: string[]; competitor_variations?: Record }; +type StreamArgs = [ + token: string, + templateId: string, + params: Record, + model: string, + onName: (name: string) => void, + onDone: (result: StreamResult) => void, +]; + +interface TestTemplate { + id: string; + title: string; + llm_enrichment?: { parameter: string }; + parameters: { name: string; label: string; type: string; required: boolean; placeholder?: string }[]; +} + +const plainTemplate: TestTemplate = { + id: "tpl-plain", + title: "Basic Redaction", + parameters: [ + { name: "org_name", label: "Organization Name", type: "string", required: true, placeholder: "e.g. Contoso" }, + { name: "note", label: "Note", type: "string", required: false, placeholder: "optional note" }, + ], +}; + +const enrichmentTemplate: TestTemplate = { + id: "tpl-competitor", + title: "Competitor Blocking", + llm_enrichment: { parameter: "brand_name" }, + parameters: [{ name: "brand_name", label: "Your Brand Name", type: "string", required: true }], +}; + +const defaultProps = { + visible: true, + template: plainTemplate, + onConfirm: vi.fn(), + onCancel: vi.fn(), + accessToken: "sk-test", +}; + +const renderModal = (props: Partial = {}) => + renderWithProviders(); + +describe("TemplateParameterModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }] }); + }); + + it("renders nothing while closed", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Basic Redaction")).not.toBeInTheDocument(); + }); + + it("shows the template title and purpose when opened", async () => { + renderModal(); + + expect(await screen.findByText("Basic Redaction")).toBeInTheDocument(); + expect(screen.getByText("Configure competitor blocking for your brand")).toBeInTheDocument(); + }); + + it("renders a labelled field per template parameter and marks the required ones", async () => { + renderModal(); + + expect((await screen.findAllByText("Organization Name")).length).toBeGreaterThan(0); + expect(screen.getAllByText("Note").length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText("e.g. Contoso").length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText("optional note").length).toBeGreaterThan(0); + }); + + it("keeps Continue disabled until every required parameter is filled", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("Basic Redaction"); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + + await user.type(screen.getAllByPlaceholderText("e.g. Contoso")[0], "Contoso"); + + expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled(); + }); + + it("hands the entered parameters back to the caller", async () => { + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderModal({ onConfirm }); + + await screen.findByText("Basic Redaction"); + await user.type(screen.getAllByPlaceholderText("e.g. Contoso")[0], "Contoso"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm.mock.calls[0][0]).toEqual({ org_name: "Contoso", note: "" }); + }); + + it("cancels back to the caller", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderModal({ onCancel }); + + await screen.findByText("Basic Redaction"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("offers AI discovery controls for an enrichment template", async () => { + renderModal({ template: enrichmentTemplate }); + + expect(await screen.findByText("Competitor Discovery")).toBeInTheDocument(); + expect(screen.getByText("Your Brand Name")).toBeInTheDocument(); + expect(screen.getByText("Select Model")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Generate Competitor Names/ })).toBeInTheDocument(); + }); + + it("loads the model list for an enrichment template", async () => { + renderModal({ template: enrichmentTemplate }); + + await waitFor(() => { + expect(modelHubCall).toHaveBeenCalledWith("sk-test"); + }); + }); + + it("hides the model picker when competitors are entered manually", async () => { + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate }); + + await screen.findByText("Competitor Discovery"); + await user.click(screen.getByText("Enter Manually")); + + await waitFor(() => { + expect(screen.queryByText("Select Model")).not.toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: /Generate Competitor Names/ })).not.toBeInTheDocument(); + }); + + it("keeps Continue disabled for an enrichment template until competitors exist", async () => { + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate }); + + await screen.findByText("Competitor Discovery"); + await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso"); + + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + }); + + it("streams discovered competitor names and enables Continue once they arrive", async () => { + enrichPolicyTemplateStream.mockImplementation(async (...args: StreamArgs) => { + const [, , , , onName, onDone] = args; + onName("Northwind"); + onDone({ competitors: ["Northwind", "Fabrikam"], competitor_variations: {} }); + }); + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate }); + + await screen.findByText("Competitor Discovery"); + await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso"); + await user.click(screen.getAllByRole("combobox")[0]); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); + await user.click(screen.getByRole("button", { name: /Generate Competitor Names/ })); + + expect(await screen.findByText("Northwind")).toBeInTheDocument(); + expect(screen.getByText("Fabrikam")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled(); + }); + }); + + it("passes the discovered competitors to the caller on confirm", async () => { + enrichPolicyTemplateStream.mockImplementation(async (...args: StreamArgs) => { + const [, , , , , onDone] = args; + onDone({ competitors: ["Northwind"] }); + }); + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate, onConfirm }); + + await screen.findByText("Competitor Discovery"); + await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso"); + await user.click(screen.getAllByRole("combobox")[0]); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); + await user.click(screen.getByRole("button", { name: /Generate Competitor Names/ })); + await screen.findByText("Northwind"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(onConfirm).toHaveBeenCalledWith({ brand_name: "Contoso" }, { competitors: ["Northwind"] }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx b/ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx new file mode 100644 index 00000000000..b9737ccd5be --- /dev/null +++ b/ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import EmailEventSettings from "./email_event_settings"; + +const { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } = vi.hoisted(() => ({ + getEmailEventSettings: vi.fn(), + updateEmailEventSettings: vi.fn(), + resetEmailEventSettings: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + getEmailEventSettings, + updateEmailEventSettings, + resetEmailEventSettings, +})); + +const settingsResponse = { + settings: [ + { event: "Virtual Key Created", enabled: true }, + { event: "New User Invitation", enabled: false }, + ], +}; + +describe("EmailEventSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + getEmailEventSettings.mockResolvedValue(settingsResponse); + updateEmailEventSettings.mockResolvedValue({}); + resetEmailEventSettings.mockResolvedValue({}); + }); + + it("renders the heading and the explanatory copy", async () => { + renderWithProviders(); + + expect(await screen.findByText("Email Notifications")).toBeInTheDocument(); + expect(screen.getByText("Select which events should trigger email notifications.")).toBeInTheDocument(); + }); + + it("renders one checkbox per event, reflecting the persisted enabled state", async () => { + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + const checkboxes = screen.getAllByRole("checkbox"); + + expect(checkboxes).toHaveLength(2); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + }); + + it("renders a per-event description", async () => { + renderWithProviders(); + + expect( + await screen.findByText(/An email will be sent to the user when a new virtual key is created/), + ).toBeInTheDocument(); + expect( + screen.getByText(/An email will be sent to the email address of the user when a new user is created/), + ).toBeInTheDocument(); + }); + + it("saves the toggled enabled flags rather than the originally fetched ones", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + await user.click(screen.getAllByRole("checkbox")[1]); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(updateEmailEventSettings).toHaveBeenCalledWith("sk-test", { + settings: [ + { event: "Virtual Key Created", enabled: true }, + { event: "New User Invitation", enabled: true }, + ], + }); + }); + }); + + it("unchecking an enabled event is persisted as disabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + await user.click(screen.getAllByRole("checkbox")[0]); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(updateEmailEventSettings).toHaveBeenCalledWith("sk-test", { + settings: [ + { event: "Virtual Key Created", enabled: false }, + { event: "New User Invitation", enabled: false }, + ], + }); + }); + }); + + it("resets to defaults and refetches the settings", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + expect(getEmailEventSettings).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Reset to Defaults" })); + + await waitFor(() => { + expect(resetEmailEventSettings).toHaveBeenCalledWith("sk-test"); + }); + await waitFor(() => { + expect(getEmailEventSettings).toHaveBeenCalledTimes(2); + }); + }); + + it("does not render event rows while the fetch is in flight", () => { + getEmailEventSettings.mockReturnValue(new Promise(() => {})); + renderWithProviders(); + + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); + }); + + it("does not call the API when there is no access token", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(getEmailEventSettings).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/email_settings.test.tsx b/ui/litellm-dashboard/src/components/email_settings.test.tsx new file mode 100644 index 00000000000..bd09ca0abc4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/email_settings.test.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import EmailSettings from "./email_settings"; + +const { serviceHealthCheck, setCallbacksCall } = vi.hoisted(() => ({ + serviceHealthCheck: vi.fn(), + setCallbacksCall: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ serviceHealthCheck, setCallbacksCall })); + +vi.mock("./email_events", () => ({ + EmailEventSettings: () =>
email event settings
, +})); + +const alerts = [ + { + name: "email", + variables: { + SMTP_HOST: "smtp.example.com", + SMTP_PORT: "587", + SMTP_PASSWORD: "********", + EMAIL_LOGO_URL: "https://example.com/logo.png", + }, + }, + { name: "slack", variables: { SLACK_WEBHOOK_URL: "https://hooks.example.com" } }, +]; + +const inputNamed = (name: string) => document.querySelector(`input[name="${name}"]`)!; + +describe("EmailSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + setCallbacksCall.mockResolvedValue({}); + serviceHealthCheck.mockResolvedValue({}); + }); + + it("renders the heading and the docs link", () => { + renderWithProviders(); + + expect(screen.getByText("Email Server Settings")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /LiteLLM Docs: email alerts/ })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/proxy/email", + ); + }); + + it("renders one named input per email variable and none for other alert types", () => { + renderWithProviders(); + + expect(inputNamed("SMTP_HOST")).toHaveValue("smtp.example.com"); + expect(inputNamed("SMTP_PORT")).toHaveValue("587"); + expect(inputNamed("SMTP_PASSWORD")).toHaveValue("********"); + expect(document.querySelector('input[name="SLACK_WEBHOOK_URL"]')).toBeNull(); + }); + + it("labels each variable and shows its help text", () => { + renderWithProviders(); + + expect(screen.getByText("SMTP_HOST")).toBeInTheDocument(); + expect(screen.getByText(/Enter the SMTP host address/)).toBeInTheDocument(); + expect(screen.getByText(/Enter the SMTP port number/)).toBeInTheDocument(); + }); + + it("submits only the fields the admin actually edited", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(inputNamed("SMTP_HOST")); + await user.type(inputNamed("SMTP_HOST"), "smtp.changed.com"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", { + general_settings: { alerting: ["email"] }, + environment_variables: { SMTP_HOST: "smtp.changed.com" }, + }); + }); + }); + + it("does not resubmit an untouched masked value", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", { + general_settings: { alerting: ["email"] }, + environment_variables: {}, + }); + }); + }); + + it("disables the premium-only fields for non-premium users", () => { + renderWithProviders(); + + expect(inputNamed("EMAIL_LOGO_URL")).toBeDisabled(); + expect(inputNamed("SMTP_HOST")).not.toBeDisabled(); + }); + + it("leaves the premium-only fields editable for premium users", () => { + renderWithProviders(); + + expect(inputNamed("EMAIL_LOGO_URL")).not.toBeDisabled(); + }); + + it("triggers a live email health check", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Test Email Alerts" })); + + await waitFor(() => { + expect(serviceHealthCheck).toHaveBeenCalledWith("sk-test", "email"); + }); + }); + + it("renders the email event settings section", () => { + renderWithProviders(); + + expect(screen.getByText("email event settings")).toBeInTheDocument(); + }); +}); From b2cf17d4a21bb1faa7c30025ef58df2244c74029 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 23:17:52 -0700 Subject: [PATCH 2/3] refactor(ui): migrate logging-and-alerts, caching and policies to shadcn Markup-only migration of the 17 files these three routes exclusively own, replacing antd and Tremor with the installed shadcn (base-vega) primitives and lucide icons. No route behaviour changes; the tests written in the previous commit are untouched here and pass against both the old and the new markup. Colour now comes from tokens rather than from hardcoded utilities, so the health-check button, the alerts and the badges no longer pin their own palette. email_settings also loses an invalid DOM nesting (a table cell inside a div, and a div inside a paragraph) that React had been warning about. Two modals on the policies page moved from the Policies panel up to the panel root. Base UI Tabs mounts only the active panel, unlike Tremor, and both are opened from the Templates tab, so leaving them nested would have made "Use Template" do nothing. Retires 53 antd import suppressions from the eslint baseline. --- ui/litellm-dashboard/eslint-suppressions.json | 63 +-- .../caching/_components/cache_dashboard.tsx | 277 +++++----- .../caching/_components/cache_health.tsx | 237 ++++----- .../cache_settings/RedisTypeSelector.tsx | 29 +- .../CoordinationRedisTypeSelector.tsx | 27 +- .../_components/ai_suggestion_modal.tsx | 454 +++++++++-------- .../_components/guardrail_selection_modal.tsx | 313 ++++++------ .../_components/impact_preview_alert.tsx | 92 ++-- .../policies/_components/index.tsx | 473 +++++++++--------- .../_components/pipeline_flow_builder.tsx | 134 ++--- .../policies/_components/policy_info.tsx | 262 +++++----- .../policies/_components/policy_templates.tsx | 162 +++--- .../_components/template_parameter_modal.tsx | 403 ++++++++------- .../CloudZeroCostTracking.tsx | 14 +- .../CloudZeroEmptyPlaceholder.tsx | 28 +- .../CloudZeroIntegrationSettings.tsx | 195 ++++---- .../email_events/email_event_settings.tsx | 76 +-- .../src/components/email_settings.tsx | 197 +++----- .../src/components/ui/radio-group.tsx | 29 ++ 19 files changed, 1734 insertions(+), 1731 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/ui/radio-group.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..a2aaf61263e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -177,9 +177,6 @@ "src/app/(dashboard)/caching/_components/cache_health.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": { @@ -187,11 +184,6 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": { "no-restricted-imports": { "count": 1 @@ -213,11 +205,6 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": { "no-restricted-imports": { "count": 1 @@ -1469,9 +1456,6 @@ "no-nested-ternary": { "count": 10 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 } @@ -1483,9 +1467,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1509,9 +1490,6 @@ "src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/policies/_components/index.test.tsx": { @@ -1526,9 +1504,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1543,9 +1518,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1554,9 +1526,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1564,9 +1533,6 @@ "src/app/(dashboard)/policies/_components/policy_templates.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { @@ -1584,9 +1550,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 }, @@ -2218,26 +2181,11 @@ "count": 1 } }, - "src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": { "no-restricted-imports": { "count": 1 @@ -3240,9 +3188,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 } @@ -3251,9 +3196,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 1 } @@ -4116,6 +4058,11 @@ "count": 1 } }, + "src/components/ui/radio-group.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/scroll-area.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 51c0b85cedb..95b73d1aacb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -1,25 +1,24 @@ -import { - Card, - Col, - DateRangePickerValue, - Grid, - Icon, - MultiSelect, - MultiSelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, -} from "@tremor/react"; +import { DateRangePickerValue } from "@tremor/react"; import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import UsageDatePicker from "@/components/shared/usage_date_picker"; import { BarChart } from "@/components/shared/charts"; -import { Card as ChartCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { RefreshIcon } from "@heroicons/react/outline"; +import { RefreshCw } from "lucide-react"; import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking"; // Import the new component @@ -258,31 +257,42 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole } }; + const statCards = [ + { label: "Cache Hit Ratio", value: `${cacheHitRatio}%` }, + { label: "Cache Hits", value: cachedResponses }, + { label: "Cached Completion Tokens", value: cachedTokens }, + ]; + return ( - - -
- Cache Analytics - Cache Health - Cache Settings - Coordination Redis -
+ +
+ + + Cache Analytics + + + Cache Health + + + Cache Settings + + + Coordination Redis + +
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - + {lastRefreshed &&

Last Refreshed: {lastRefreshed}

} +
- - - - - +
+ + + + +

Analytics for LiteLLM's{" "} = ({ accessToken, token, userRole {" "} (cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching Metrics" on the Usage page or individual requests in the Logs page. - - - - - {uniqueApiKeys.map((key) => ( - - {key} - - ))} - - - - - {uniqueModels.map((model) => ( - - {model} - - ))} - - - - { - setDateValue(value); - updateCachingData(value.from, value.to); - }} - /> - - +

-
- -

- Cache Hit Ratio -

-
-

- {cacheHitRatio}% -

-
-
- -

- Cache Hits -

-
-

- {cachedResponses} -

-
-
+
+ setSelectedApiKeys(keys)} + > + + + {(keys: string[]) => + keys.map((key) => ( + + {key} + + )) + } + + + + + No virtual keys found + + {(key: string) => ( + + {key} + + )} + + + - -

- Cached Completion Tokens -

-
-

- {cachedTokens} -

-
-
+ setSelectedModels(models)} + > + + + {(models: string[]) => + models.map((model) => ( + + {model} + + )) + } + + + + + No models found + + {(model: string) => ( + + {model} + + )} + + + + + { + setDateValue(value); + updateCachingData(value.from, value.to); + }} + />
- +
+ {statCards.map((stat) => ( + + +

{stat.label}

+
+

{stat.value}

+
+
+
+ ))} +
+ + Cache Hits vs API Requests @@ -387,9 +413,9 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole yAxisWidth={48} /> -
+ - + Cached Completion Tokens vs Generated Completion Tokens @@ -406,24 +432,27 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole yAxisWidth={48} /> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx index 887c12a4f3d..85649c1cf26 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Text, Button, TabGroup, TabList, Tab, TabPanel, TabPanels } from "@tremor/react"; -import { CheckCircleIcon, XCircleIcon, ClipboardCopyIcon } from "@heroicons/react/outline"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CheckCircle2, XCircle, ClipboardCopy } from "lucide-react"; import { ResponseTimeIndicator } from "./response_time_indicator"; // Helper function to deep-parse a JSON string if possible @@ -30,22 +31,26 @@ const TableClickableErrorField: React.FC<{ label: string; value: string | null | }; return ( - + -
-
-
-
{label}
-
-                {isExpanded ? safeValue : truncated}
-              
+
{label}
+
{isExpanded ? safeValue : truncated}
-
@@ -151,112 +156,114 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => { }; return ( -
- - - Summary - Raw Response - +
+ + + + Summary + + + Raw Response + + - - -
-
- {response?.status === "healthy" ? ( - - ) : ( - + +
+
+ {response?.status === "healthy" ? ( + + ) : ( + + )} +

+ Cache Status: {response?.status || "unhealthy"} +

+
+ + + + {/* Show error message if present */} + {errorDetails && ( + <> + + + + + + )} - - Cache Status: {response?.status || "unhealthy"} - - -
+ Error Details +
- - {/* Show error message if present */} - {errorDetails && ( - <> - - - - - - - )} + {/* Always show cache details, regardless of error state */} + + + + + + + - {/* Always show cache details, regardless of error state */} - - - - - - - + {/* Redis Details Section */} + {parsedLitellmParams?.type === "redis" && ( + <> + + + + + + + + + + )} + +
- Error Details -
+ Cache Details +
- Cache Details -
+ Redis Details +
+
+
- {/* Redis Details Section */} - {parsedLitellmParams?.type === "redis" && ( - <> - - - Redis Details - - - - - - - - - )} - - -
- - - -
-
-                {(() => {
-                  try {
-                    const data = {
-                      ...response,
-                      litellm_cache_params: parsedLitellmParams,
-                      health_check_cache_params: parsedRedisParams,
-                    };
-                    // First parse any string JSON values
-                    const prettyData = JSON.parse(
-                      JSON.stringify(data, (key, value) => {
-                        if (typeof value === "string") {
-                          try {
-                            return JSON.parse(value);
-                          } catch {
-                            return value;
-                          }
+        
+          
+
+              {(() => {
+                try {
+                  const data = {
+                    ...response,
+                    litellm_cache_params: parsedLitellmParams,
+                    health_check_cache_params: parsedRedisParams,
+                  };
+                  // First parse any string JSON values
+                  const prettyData = JSON.parse(
+                    JSON.stringify(data, (key, value) => {
+                      if (typeof value === "string") {
+                        try {
+                          return JSON.parse(value);
+                        } catch {
+                          return value;
                         }
-                        return value;
-                      }),
-                    );
-                    // Then stringify with proper formatting
-                    return JSON.stringify(prettyData, null, 2);
-                  } catch (e) {
-                    return "Error formatting JSON: " + (e as Error).message;
-                  }
-                })()}
-              
-
- - - + } + return value; + }), + ); + // Then stringify with proper formatting + return JSON.stringify(prettyData, null, 2); + } catch (e) { + return "Error formatting JSON: " + (e as Error).message; + } + })()} +
+
+ +
); }; @@ -282,11 +289,7 @@ export const CacheHealthTab: React.FC<{ return (
- diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx index fbca7ab5a97..40f553e6fde 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Select, SelectItem } from "@tremor/react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; interface RedisTypeSelectorProps { redisType: string; @@ -7,17 +7,30 @@ interface RedisTypeSelectorProps { onTypeChange: (type: string) => void; } +const REDIS_TYPE_LABELS: Readonly> = { + node: "Node (Single Instance)", + cluster: "Cluster", + sentinel: "Sentinel", + semantic: "Semantic", +}; + const RedisTypeSelector: React.FC = ({ redisType, redisTypeDescriptions, onTypeChange }) => { return (
- - value !== null && onTypeChange(value)}> + + {REDIS_TYPE_LABELS[redisType] ?? redisType} + + + {Object.entries(REDIS_TYPE_LABELS).map(([value, label]) => ( + + {label} + + ))} + -

+

{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx index daab8505890..e6385e83649 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Select } from "antd"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { COORDINATION_REDIS_TYPES, COORDINATION_REDIS_TYPE_DESCRIPTIONS, @@ -12,21 +12,24 @@ interface CoordinationRedisTypeSelectorProps { onTypeChange: (type: CoordinationRedisType) => void; } -const OPTIONS = COORDINATION_REDIS_TYPES.map((type) => ({ value: type, label: COORDINATION_REDIS_TYPE_LABELS[type] })); - const CoordinationRedisTypeSelector: React.FC = ({ redisType, onTypeChange }) => (
-
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx index 34204624702..cbf24b425f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd"; -import { Button, Card } from "@tremor/react"; -import { - CheckCircleOutlined, - CloseCircleOutlined, - InfoCircleOutlined, - DownOutlined, - RightOutlined, -} from "@ant-design/icons"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { CheckCircle2, ChevronDown, ChevronRight, Info, XCircle } from "lucide-react"; import { suggestPolicyTemplates, modelHubCall, @@ -15,9 +16,6 @@ import { enrichPolicyTemplateStream, } from "@/components/networking"; -const { TextArea } = Input; -const { Text } = Typography; - interface SuggestedTemplate { template_id: string; reason: string; @@ -427,7 +425,7 @@ const AiSuggestionModal: React.FC = ({
toggleTemplate(suggestion.template_id)} + onCheckedChange={() => toggleTemplate(suggestion.template_id)} className="mt-0.5" />
@@ -447,20 +445,25 @@ const AiSuggestionModal: React.FC = ({ )} {template.estimated_latency_ms != null && ( - - + + } > +{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency - + + Estimated latency overhead added to each request )}
-

{template.description}

+

{template.description}

{template.guardrails && template.guardrails.slice(0, 4).map((g: string) => ( @@ -476,7 +479,7 @@ const AiSuggestionModal: React.FC = ({ )}
- +

{suggestion.reason}

@@ -490,7 +493,7 @@ const AiSuggestionModal: React.FC = ({ {explanation && (
- + Why these templates @@ -555,7 +558,7 @@ const AiSuggestionModal: React.FC = ({ >
{hasEnrichedGuardrails ? ( - + ) : ( = ({
setEnrichBrandName(e.target.value)} - onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()} + onKeyDown={(e) => { + if (e.key === "Enter" && enrichBrandName.trim() && !isEnriching) handleEnrichCompetitors(); + }} className="flex-1" /> -
{isEnriching && enrichStatusMessage && ( -
- +
+ {enrichStatusMessage}
)} {hasEnrichedGuardrails && (
- + Competitor names loaded for {enrichBrandName}
)} @@ -631,33 +630,29 @@ const AiSuggestionModal: React.FC = ({
- - + + } /> + Press Enter to submit. Use Shift+Enter for new line.
- Characters: {testInputText.length} + Characters: {testInputText.length}
-