From 05fb750d7c3d09a27cfc0e51292fe76a78b2013d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 17 Dec 2025 19:03:39 -0800 Subject: [PATCH] UI Cloud Zero Integration --- .../hooks/cloudzero/useCloudZeroCreate.ts | 51 +++++ .../hooks/cloudzero/useCloudZeroDryRun.ts | 47 +++++ .../hooks/cloudzero/useCloudZeroExport.ts | 47 +++++ .../hooks/cloudzero/useCloudZeroSettings.ts | 100 +++++++++ .../CloudZeroCostTracking.test.tsx | 53 +++++ .../CloudZeroCostTracking.tsx | 62 ++++++ .../CloudZeroCreateModal.test.tsx | 55 +++++ .../CloudZeroCreateModal.tsx | 100 +++++++++ .../CloudZeroEmptyPlaceholder.test.tsx | 14 ++ .../CloudZeroEmptyPlaceholder.tsx | 29 +++ .../CloudZeroIntegrationSettings.test.tsx | 71 +++++++ .../CloudZeroIntegrationSettings.tsx | 193 ++++++++++++++++++ .../CloudZeroUpdateModal.test.tsx | 62 ++++++ .../CloudZeroUpdateModal.tsx | 109 ++++++++++ .../components/CloudZeroCostTracking/types.ts | 6 + .../src/components/settings.test.tsx | 16 ++ .../src/components/settings.tsx | 7 + 17 files changed, 1022 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroExport.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx create mode 100644 ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate.ts new file mode 100644 index 00000000000..e1263903622 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate.ts @@ -0,0 +1,51 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useMutation } from "@tanstack/react-query"; + +interface CreateParams { + connection_id: string; + timezone?: string; + api_key?: string; +} + +interface CreateResponse { + [key: string]: any; +} + +const performCloudZeroCreate = async (accessToken: string, params: CreateParams): Promise => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/cloudzero/init` : `/cloudzero/init`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection_id: params.connection_id, + timezone: params.timezone ?? "UTC", + ...(params.api_key && { api_key: params.api_key }), + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to create CloudZero integration"; + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; +}; + +export const useCloudZeroCreate = (accessToken: string) => { + return useMutation({ + mutationFn: async (params: CreateParams) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return await performCloudZeroCreate(accessToken, params); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun.ts new file mode 100644 index 00000000000..1ed8a141603 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun.ts @@ -0,0 +1,47 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useMutation } from "@tanstack/react-query"; + +interface DryRunParams { + limit?: number; +} + +interface DryRunResponse { + [key: string]: any; +} + +const performCloudZeroDryRun = async (accessToken: string, params: DryRunParams = {}): Promise => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/cloudzero/dry-run` : `/cloudzero/dry-run`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + limit: params.limit ?? 10, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to perform dry run"; + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; +}; + +export const useCloudZeroDryRun = (accessToken: string) => { + return useMutation({ + mutationFn: async (params: DryRunParams = {}) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return await performCloudZeroDryRun(accessToken, params); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroExport.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroExport.ts new file mode 100644 index 00000000000..47d559b20d2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroExport.ts @@ -0,0 +1,47 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useMutation } from "@tanstack/react-query"; + +interface ExportParams { + operation?: string; +} + +interface ExportResponse { + [key: string]: any; +} + +const performCloudZeroExport = async (accessToken: string, params: ExportParams = {}): Promise => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/cloudzero/export` : `/cloudzero/export`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operation: params.operation ?? "replace_hourly", + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to export data"; + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; +}; + +export const useCloudZeroExport = (accessToken: string) => { + return useMutation({ + mutationFn: async (params: ExportParams = {}) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return await performCloudZeroExport(accessToken, params); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts new file mode 100644 index 00000000000..2ef23e28247 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts @@ -0,0 +1,100 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { CloudZeroSettings } from "@/components/CloudZeroCostTracking/types"; + +const cloudZeroSettingsKeys = createQueryKeys("cloudZeroSettings"); + +const getCloudZeroSettings = async (accessToken: string): Promise => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/cloudzero/settings` : `/cloudzero/settings`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.status === 404) { + // 404 means no settings are configured - this is expected and not an error + return null; + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to fetch CloudZero settings"; + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; +}; + +export const useCloudZeroSettings = (accessToken: string) => { + return useQuery({ + queryKey: cloudZeroSettingsKeys.list({}), + queryFn: async () => await getCloudZeroSettings(accessToken), + enabled: !!accessToken && !!getProxyBaseUrl(), + staleTime: 60 * 60 * 1000, // 1 hour - data rarely changes + gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour + }); +}; + +interface UpdateParams { + connection_id?: string; + timezone?: string; + api_key?: string; +} + +interface UpdateResponse { + message: string; + status: string; +} + +const updateCloudZeroSettings = async (accessToken: string, params: UpdateParams): Promise => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl ? `${proxyBaseUrl}/cloudzero/settings` : `/cloudzero/settings`; + + const response = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...(params.connection_id && { connection_id: params.connection_id }), + ...(params.timezone && { timezone: params.timezone }), + ...(params.api_key && { api_key: params.api_key }), + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to update CloudZero settings"; + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; +}; + +export const useCloudZeroUpdateSettings = (accessToken: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: UpdateParams) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return await updateCloudZeroSettings(accessToken, params); + }, + onSuccess: () => { + // Invalidate the settings query to refetch updated data + queryClient.invalidateQueries({ queryKey: cloudZeroSettingsKeys.list({}) }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.test.tsx new file mode 100644 index 00000000000..972092cd2d9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import CloudZeroCostTracking from "./CloudZeroCostTracking"; + +const mockUseCloudZeroSettings = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: () => ({ + accessToken: "test-token", + }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings", () => ({ + useCloudZeroSettings: () => mockUseCloudZeroSettings(), +})); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "http://test-proxy", +})); + +describe("CloudZeroCostTracking", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + vi.clearAllMocks(); + mockUseCloudZeroSettings.mockReturnValue({ + data: null, + isLoading: false, + error: null, + }); + }); + + it("should render", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("No CloudZero Integration Found")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx new file mode 100644 index 00000000000..fbb892cb1d8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx @@ -0,0 +1,62 @@ +import { useCloudZeroSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { Card, Typography } from "antd"; +import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder"; +import { useState } from "react"; +import CloudZeroCreationModal from "./CloudZeroCreateModal"; +import { useQueryClient } from "@tanstack/react-query"; +import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory"; +import { CloudZeroIntegrationSettings } from "./CloudZeroIntegrationSettings"; + +export default function CloudZeroCostTracking() { + const { accessToken } = useAuthorized(); + const { data: settings, isLoading, error } = useCloudZeroSettings(accessToken); + const queryClient = useQueryClient(); + const cloudZeroSettingsKeys = createQueryKeys("cloudZeroSettings"); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const handleCreateModalOk = async () => { + setIsCreateModalOpen(false); + await queryClient.invalidateQueries({ queryKey: cloudZeroSettingsKeys.list({}) }); + }; + + const handleCreateModalCancel = () => { + setIsCreateModalOpen(false); + }; + + if (isLoading) { + return ( + + Loading CloudZero settings... + + ); + } + + if (error) { + return ( + + Error loading CloudZero settings: {error.message} + + ); + } + + if (!settings) { + return ( + <> + setIsCreateModalOpen(true)} /> + + + ); + } + + return ( + <> + + + ); +} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx new file mode 100644 index 00000000000..1a848848344 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import CloudZeroCreateModal from "./CloudZeroCreateModal"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: () => ({ + accessToken: "test-token", + }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate", () => ({ + useCloudZeroCreate: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("antd", async () => { + const actual = await vi.importActual("antd"); + return { + ...actual, + message: { + success: vi.fn(), + error: vi.fn(), + }, + }; +}); + +describe("CloudZeroCreateModal", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + }); + + it("should render", () => { + render( + + + , + ); + + expect(screen.getByText("Create CloudZero Integration")).toBeInTheDocument(); + expect(screen.getByLabelText("CloudZero API Key")).toBeInTheDocument(); + expect(screen.getByLabelText("Connection ID")).toBeInTheDocument(); + expect(screen.getByLabelText("Timezone")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx new file mode 100644 index 00000000000..feb00fc0404 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx @@ -0,0 +1,100 @@ +import { Form, Modal, Input, message } from "antd"; +import { useEffect } from "react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate"; + +interface CloudZeroCreationModalProps { + open: boolean; + onOk: () => void; + onCancel: () => void; +} + +export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZeroCreationModalProps) { + const { accessToken } = useAuthorized(); + const [form] = Form.useForm(); + const createMutation = useCloudZeroCreate(accessToken || ""); + + useEffect(() => { + if (open) { + form.resetFields(); + } + }, [open, form]); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + createMutation.mutate( + { + connection_id: values.connection_id, + timezone: values.timezone || "UTC", + ...(values.api_key && { api_key: values.api_key }), + }, + { + onSuccess: () => { + message.success("CloudZero integration created successfully"); + form.resetFields(); + onOk(); + }, + onError: (error: any) => { + if (error?.errorFields) { + return; + } + message.error(error?.message || "Failed to create CloudZero integration"); + }, + }, + ); + } catch (error: any) { + if (error?.errorFields) { + return; + } + message.error(error?.message || "Failed to create CloudZero integration"); + } + }; + + const handleCancel = () => { + form.resetFields(); + onCancel(); + }; + + return ( + +
+ + + + + + + + + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx new file mode 100644 index 00000000000..04e0a67dea6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder"; + +describe("CloudZeroEmptyPlaceholder", () => { + it("should render", () => { + const startCreation = vi.fn(); + render(); + + expect(screen.getByText("No CloudZero Integration Found")).toBeInTheDocument(); + expect(screen.getByText(/Connect your CloudZero account/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Integration" })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx new file mode 100644 index 00000000000..1719a949b86 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx @@ -0,0 +1,29 @@ +import { Empty, Typography, Button } from "antd"; + +const { Title, Paragraph } = Typography; + +interface CloudZeroEmptyPlaceholderProps { + startCreation: () => void; +} + +export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) { + return ( +
+ + No CloudZero Integration Found + + Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM. + +
+ } + > + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.test.tsx new file mode 100644 index 00000000000..350c0a3c4da --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CloudZeroIntegrationSettings } from "./CloudZeroIntegrationSettings"; +import { CloudZeroSettings } from "./types"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: () => ({ + accessToken: "test-token", + }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun", () => ({ + useCloudZeroDryRun: () => ({ + mutate: vi.fn(), + isPending: false, + data: null, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroExport", () => ({ + useCloudZeroExport: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("antd", async () => { + const actual = await vi.importActual("antd"); + return { + ...actual, + message: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, + }; +}); + +describe("CloudZeroIntegrationSettings", () => { + let queryClient: QueryClient; + const mockSettings: CloudZeroSettings = { + connection_id: "test-connection-id", + api_key_masked: "****", + timezone: "UTC", + status: "Active", + }; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + }); + + it("should render", () => { + render( + + + , + ); + + expect(screen.getByText("CloudZero Configuration")).toBeInTheDocument(); + expect(screen.getByText("API Key (Redacted)")).toBeInTheDocument(); + expect(screen.getByText("Connection ID")).toBeInTheDocument(); + expect(screen.getByText("Timezone")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx new file mode 100644 index 00000000000..b663b83606d --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx @@ -0,0 +1,193 @@ +import React, { useState } from "react"; +import { Card, Descriptions, Button, Tag, Popconfirm, Alert, Divider, message } from "antd"; +import { Edit, Trash2, Play, Upload, CheckCircle } from "lucide-react"; +import { getProxyBaseUrl } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCloudZeroDryRun } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun"; +import { useCloudZeroExport } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroExport"; +import CloudZeroUpdateModal from "./CloudZeroUpdateModal"; +import { CloudZeroSettings } from "./types"; + +interface CloudZeroIntegrationSettingsProps { + settings: CloudZeroSettings; + onSettingsUpdated: () => void; +} + +export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) { + const { accessToken } = useAuthorized(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const dryRunMutation = useCloudZeroDryRun(accessToken || ""); + const exportMutation = useCloudZeroExport(accessToken || ""); + + const handleDryRun = () => { + if (!accessToken) return; + + dryRunMutation.mutate( + { limit: 10 }, + { + onSuccess: (data) => { + message.success("Dry run completed successfully"); + }, + onError: (error) => { + message.error(error?.message || "Failed to perform dry run"); + }, + }, + ); + }; + + const dryRunResult = dryRunMutation.data ? JSON.stringify(dryRunMutation.data, null, 2) : null; + + const handleExport = () => { + if (!accessToken) return; + + exportMutation.mutate( + { operation: "replace_hourly" }, + { + onSuccess: () => { + message.success("Data successfully exported to CloudZero"); + }, + onError: (error) => { + message.error(error?.message || "Failed to export data"); + }, + }, + ); + }; + + const handleEdit = () => { + setIsEditModalOpen(true); + }; + + const handleEditModalOk = async () => { + setIsEditModalOpen(false); + onSettingsUpdated(); + }; + + const handleEditModalCancel = () => { + setIsEditModalOpen(false); + }; + + const handleDelete = async () => { + // Note: Delete functionality is not yet implemented in the backend API + // This would require a DELETE endpoint at /cloudzero/settings + message.warning("Delete functionality is not yet available. Please contact support."); + }; + + return ( + <> +
+ + CloudZero Configuration + + {settings.status || "Active"} + +
+ } + extra={ +
+ + + + +
+ } + className="shadow-sm" + > + + + {settings.api_key_masked} + + + {settings.connection_id} + + + {settings.timezone || Default (UTC)} + + + + + Actions + + +
+ + + + + +
+ + {dryRunResult && ( +
+ +

Simulation output for connection: {settings.connection_id}

+
+                      {dryRunResult}
+                    
+
+ } + type="info" + showIcon + icon={} + /> + + )} + + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx new file mode 100644 index 00000000000..fdb3249b5b6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import CloudZeroUpdateModal from "./CloudZeroUpdateModal"; +import { CloudZeroSettings } from "./types"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: () => ({ + accessToken: "test-token", + }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings", () => ({ + useCloudZeroUpdateSettings: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("antd", async () => { + const actual = await vi.importActual("antd"); + return { + ...actual, + message: { + success: vi.fn(), + error: vi.fn(), + }, + }; +}); + +describe("CloudZeroUpdateModal", () => { + let queryClient: QueryClient; + const mockSettings: CloudZeroSettings = { + connection_id: "test-connection-id", + api_key_masked: "****", + timezone: "UTC", + status: "Active", + }; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + }); + + it("should render", () => { + render( + + + , + ); + + expect(screen.getByText("Edit CloudZero Integration")).toBeInTheDocument(); + expect(screen.getByLabelText("CloudZero API Key")).toBeInTheDocument(); + expect(screen.getByLabelText("Connection ID")).toBeInTheDocument(); + expect(screen.getByLabelText("Timezone")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx new file mode 100644 index 00000000000..0ec080bf24a --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx @@ -0,0 +1,109 @@ +import { Form, Modal, Input, message } from "antd"; +import { useState, useEffect } from "react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; +import { CloudZeroSettings } from "./types"; + +interface CloudZeroUpdateModalProps { + open: boolean; + onOk: () => void; + onCancel: () => void; + settings: CloudZeroSettings; +} + +export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: CloudZeroUpdateModalProps) { + const { accessToken } = useAuthorized(); + const [form] = Form.useForm(); + const updateMutation = useCloudZeroUpdateSettings(accessToken || ""); + + useEffect(() => { + if (open && settings) { + form.setFieldsValue({ + connection_id: settings.connection_id, + timezone: settings.timezone || "UTC", + api_key: "", + }); + } else if (open) { + form.resetFields(); + } + }, [open, settings, form]); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + updateMutation.mutate( + { + connection_id: values.connection_id, + timezone: values.timezone || "UTC", + ...(values.api_key && { api_key: values.api_key }), + }, + { + onSuccess: () => { + message.success("CloudZero integration updated successfully"); + form.resetFields(); + onOk(); + }, + onError: (error: any) => { + if (error?.errorFields) { + return; + } + message.error(error?.message || "Failed to update CloudZero integration"); + }, + }, + ); + } catch (error: any) { + if (error?.errorFields) { + return; + } + message.error(error?.message || "Failed to update CloudZero integration"); + } + }; + + const handleCancel = () => { + form.resetFields(); + onCancel(); + }; + + return ( + +
+ + + + + + + + + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts new file mode 100644 index 00000000000..a41afee4f72 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts @@ -0,0 +1,6 @@ +export interface CloudZeroSettings { + api_key_masked: string; + connection_id: string; + timezone?: string; + status?: string; +} diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index 4086acf91a4..7776d0c7082 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -33,6 +33,11 @@ vi.mock("./email_settings", () => ({ default: () =>
Mock Email Settings
, })); +vi.mock("./CloudZeroCostTracking/CloudZeroCostTracking", () => ({ + __esModule: true, + default: () =>
Mock CloudZero Cost Tracking
, +})); + // Polyfill ResizeObserver for components relying on it in tests if (typeof window !== "undefined" && !window.ResizeObserver) { window.ResizeObserver = class ResizeObserver { @@ -92,6 +97,7 @@ describe("Settings", () => { const { getByText } = render(); await waitFor(() => { + expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument(); expect(getByText("Alerting Types")).toBeInTheDocument(); expect(getByText("Alerting Settings")).toBeInTheDocument(); expect(getByText("Email Alerts")).toBeInTheDocument(); @@ -187,4 +193,14 @@ describe("Settings", () => { expect(getByText("Host")).toBeInTheDocument(); }); }); + + it("should display CloudZero Cost Tracking tab", async () => { + const { getByText } = render(); + + await waitFor(() => { + expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + + expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 80b5653e75a..dfe141b2a4b 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -39,6 +39,7 @@ import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallba import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types"; import { parseErrorMessage } from "./shared/errorUtils"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; +import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; interface SettingsPageProps { accessToken: string | null; userRole: string | null; @@ -568,6 +569,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, Logging Callbacks + CloudZero Cost Tracking Alerting Types Alerting Settings Email Alerts @@ -593,6 +595,11 @@ const Settings: React.FC = ({ accessToken, userRole, userID, }} /> + +
+ +
+