diff --git a/ui/litellm-dashboard/docs/CYCLES.md b/ui/litellm-dashboard/docs/CYCLES.md index 8b4b4efaac3..b1cbf270a71 100644 --- a/ui/litellm-dashboard/docs/CYCLES.md +++ b/ui/litellm-dashboard/docs/CYCLES.md @@ -89,6 +89,17 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest** Tests updated to use semantic queries (button disabled state) and to match the rhf submit shape (object containing tag_name). +## 19. Cost Tracking — CloudZero (cost-tracking, cloudzero subset) + +- Cycles used: 1 / 7 +- Layer outcomes per cycle: + - cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (6/6) | Build ✓ | Parity ⏭ | Snap ⏭ +- Final status: **done (cloudzero subset)** +- Scope: CloudZeroCostTracking, CloudZeroCreateModal, CloudZeroEmptyPlaceholder, + CloudZeroIntegrationSettings, CloudZeroUpdateModal. The broader cost-tracking + section (CostTrackingSettings/*) is pending in a follow-up commit. +- Tests updated for required-field label regex matching. + ## 24. Guardrails Monitor (guardrails-monitor) - Cycles used: 1 / 7 diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx index db3ea94bbf9..3954053e143 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx @@ -1,6 +1,6 @@ import { useCloudZeroSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { Card, Typography } from "antd"; +import { Card } from "@/components/ui/card"; import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder"; import { useState } from "react"; import CloudZeroCreationModal from "./CloudZeroCreateModal"; @@ -10,7 +10,11 @@ import { CloudZeroIntegrationSettings } from "./CloudZeroIntegrationSettings"; export default function CloudZeroCostTracking() { const { accessToken } = useAuthorized(); - const { data: settings, isLoading, error } = useCloudZeroSettings(accessToken); + const { + data: settings, + isLoading, + error, + } = useCloudZeroSettings(accessToken); const queryClient = useQueryClient(); const cloudZeroSettingsKeys = createQueryKeys("cloudZeroSettings"); @@ -18,27 +22,28 @@ export default function CloudZeroCostTracking() { const handleCreateModalOk = async () => { setIsCreateModalOpen(false); - await queryClient.invalidateQueries({ queryKey: cloudZeroSettingsKeys.list({}) }); + await queryClient.invalidateQueries({ + queryKey: cloudZeroSettingsKeys.list({}), + }); }; - const handleCreateModalCancel = () => { - setIsCreateModalOpen(false); - }; + const handleCreateModalCancel = () => setIsCreateModalOpen(false); if (isLoading) { return ( - - Loading CloudZero settings... + + Loading CloudZero settings... ); } if (error) { return ( - - - Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)} - + + + Error loading CloudZero settings:{" "} + {error instanceof Error ? error.message : String(error)} + ); } @@ -46,7 +51,9 @@ export default function CloudZeroCostTracking() { if (!settings) { return ( <> - setIsCreateModalOpen(true)} /> + setIsCreateModalOpen(true)} + /> - - + ); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx index 1a848848344..70fd3e7b17c 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.test.tsx @@ -48,8 +48,9 @@ describe("CloudZeroCreateModal", () => { ); expect(screen.getByText("Create CloudZero Integration")).toBeInTheDocument(); - expect(screen.getByLabelText("CloudZero API Key")).toBeInTheDocument(); - expect(screen.getByLabelText("Connection ID")).toBeInTheDocument(); + // Required-field labels include a required indicator post phase-1. + 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 index 5f460cb7cb6..9a683a0b66a 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx @@ -1,8 +1,21 @@ -import { Form, Modal, Input } from "antd"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import MessageManager from "@/components/molecules/message_manager"; import { useEffect } from "react"; +import { Eye, EyeOff } from "lucide-react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate"; +import { FormProvider, useForm } from "react-hook-form"; +import { useState } from "react"; interface CloudZeroCreationModalProps { open: boolean; @@ -10,92 +23,155 @@ interface CloudZeroCreationModalProps { onCancel: () => void; } -export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZeroCreationModalProps) { +interface CreateValues { + api_key: string; + connection_id: string; + timezone: string; +} + +const defaultValues: CreateValues = { + api_key: "", + connection_id: "", + timezone: "", +}; + +export default function CloudZeroCreationModal({ + open, + onOk, + onCancel, +}: CloudZeroCreationModalProps) { const { accessToken } = useAuthorized(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues, + mode: "onSubmit", + }); const createMutation = useCloudZeroCreate(accessToken || ""); + const [showApiKey, setShowApiKey] = useState(false); useEffect(() => { - if (open) { - form.resetFields(); - } - }, [open, form]); + if (open) form.reset(defaultValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); - 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 }), + const handleSubmit = form.handleSubmit((values) => { + createMutation.mutate( + { + connection_id: values.connection_id, + timezone: values.timezone || "UTC", + ...(values.api_key && { api_key: values.api_key }), + }, + { + onSuccess: () => { + MessageManager.success( + "CloudZero integration created successfully", + ); + form.reset(defaultValues); + onOk(); }, - { - onSuccess: () => { - MessageManager.success("CloudZero integration created successfully"); - form.resetFields(); - onOk(); - }, - onError: (error: any) => { - if (error?.errorFields) { - return; - } - MessageManager.error(error?.message || "Failed to create CloudZero integration"); - }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError: (error: any) => { + MessageManager.error( + error?.message || "Failed to create CloudZero integration", + ); }, - ); - } catch (error: any) { - if (error?.errorFields) { - return; - } - MessageManager.error(error?.message || "Failed to create CloudZero integration"); - } - }; + }, + ); + }); const handleCancel = () => { - form.resetFields(); + form.reset(defaultValues); onCancel(); }; return ( - (!o ? handleCancel() : undefined)} > -
- - - - - - - - - -
-
+ + + Create CloudZero Integration + + Configure a new CloudZero billing integration. + + + +
+
+ +
+ + +
+ {form.formState.errors.api_key && ( +

+ {form.formState.errors.api_key.message as string} +

+ )} +
+ +
+ + + {form.formState.errors.connection_id && ( +

+ {form.formState.errors.connection_id.message as string} +

+ )} +
+ +
+ + +

+ Timezone for date handling (defaults to UTC if not provided) +

+
+ + + + + +
+
+
+ ); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx index aca074dc290..d8bfa106163 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx @@ -1,29 +1,28 @@ -import { Empty, Typography, Button } from "antd"; - -const { Title, Paragraph } = Typography; +import { Button } from "@/components/ui/button"; +import { Inbox } from "lucide-react"; interface CloudZeroEmptyPlaceholderProps { startCreation: () => void; } -export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) { +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.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx index 62709dac526..8e98ff014b5 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx @@ -3,7 +3,22 @@ import { useCloudZeroExport } from "@/app/(dashboard)/hooks/cloudzero/useCloudZe import { useCloudZeroDeleteSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; -import { Alert, Button, Card, Descriptions, Divider, Popconfirm, Tag } from "antd"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import MessageManager from "@/components/molecules/message_manager"; import { CheckCircle, Edit, Play, Trash2, Upload } from "lucide-react"; import { useState } from "react"; @@ -15,7 +30,10 @@ interface CloudZeroIntegrationSettingsProps { onSettingsUpdated: () => void; } -export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) { +export function CloudZeroIntegrationSettings({ + settings, + onSettingsUpdated, +}: CloudZeroIntegrationSettingsProps) { const { accessToken } = useAuthorized(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -26,11 +44,10 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl const handleDryRun = () => { if (!accessToken) return; - dryRunMutation.mutate( { limit: 10 }, { - onSuccess: (data) => { + onSuccess: () => { MessageManager.success("Dry run completed successfully"); }, onError: (error) => { @@ -40,11 +57,12 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl ); }; - const dryRunResult = dryRunMutation.data ? JSON.stringify(dryRunMutation.data, null, 2) : null; + const dryRunResult = dryRunMutation.data + ? JSON.stringify(dryRunMutation.data, null, 2) + : null; const handleExport = () => { if (!accessToken) return; - exportMutation.mutate( { operation: "replace_hourly" }, { @@ -58,26 +76,13 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl ); }; - const handleEdit = () => { - setIsEditModalOpen(true); - }; - const handleEditModalOk = async () => { setIsEditModalOpen(false); onSettingsUpdated(); }; - const handleEditModalCancel = () => { - setIsEditModalOpen(false); - }; - - const handleDeleteClick = () => { - setIsDeleteModalOpen(true); - }; - const handleDeleteConfirm = () => { if (!accessToken) return; - deleteMutation.mutate(undefined, { onSuccess: () => { MessageManager.success("CloudZero integration deleted successfully"); @@ -85,118 +90,143 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl onSettingsUpdated(); }, onError: (error) => { - MessageManager.error(error?.message || "Failed to delete CloudZero integration"); + MessageManager.error( + error?.message || "Failed to delete CloudZero integration", + ); }, }); }; - const handleDeleteCancel = () => { - setIsDeleteModalOpen(false); - }; - return ( <>
- +
- CloudZero Configuration - + + CloudZero Configuration + + {settings.status || "Active"} - +
- } - extra={
-
- } - className="shadow-sm" - > - - - - {settings.api_key_masked || Not configured} - - - - - {settings.connection_id || Not configured} - - - - {settings.timezone || Default (UTC)} - - +
- +
+
+
+ API Key (Redacted) +
+
+ + {settings.api_key_masked || ( + + Not configured + + )} + +
+
+
+
Connection ID
+
+ + {settings.connection_id || ( + + Not configured + + )} + +
+
+
+
Timezone
+
+ {settings.timezone || ( + + Default (UTC) + + )} +
+
+
+ + +

Actions - +

- - - + + + + + + + + Export Data to CloudZero + + + This will push the current accumulated cost data to + CloudZero. Continue? + + + + Cancel + + Export + + + +
{dryRunResult && ( -
- -

Simulation output for connection: {settings.connection_id}

-
-                      {dryRunResult}
-                    
-
- } - type="info" - showIcon - icon={} - /> +
+ + + Dry Run Results + +

+ Simulation output for connection: {settings.connection_id} +

+
+                    {dryRunResult}
+                  
+
+
)}
@@ -205,7 +235,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl setIsEditModalOpen(false)} settings={settings} /> @@ -225,7 +255,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl value: settings.timezone || "Default (UTC)", }, ]} - onCancel={handleDeleteCancel} + onCancel={() => setIsDeleteModalOpen(false)} onOk={handleDeleteConfirm} confirmLoading={deleteMutation.isPending} /> diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx index fdb3249b5b6..5841b88de1e 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.test.tsx @@ -56,7 +56,9 @@ describe("CloudZeroUpdateModal", () => { expect(screen.getByText("Edit CloudZero Integration")).toBeInTheDocument(); expect(screen.getByLabelText("CloudZero API Key")).toBeInTheDocument(); - expect(screen.getByLabelText("Connection ID")).toBeInTheDocument(); + // Connection ID label includes a required indicator post phase-1; match + // by partial text. + 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 index a04007897c3..bcddfe57393 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx @@ -1,9 +1,21 @@ import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { Form, Input, Modal } from "antd"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import MessageManager from "@/components/molecules/message_manager"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; import { CloudZeroSettings } from "./types"; +import { FormProvider, useForm } from "react-hook-form"; interface CloudZeroUpdateModalProps { open: boolean; @@ -12,99 +24,152 @@ interface CloudZeroUpdateModalProps { settings: CloudZeroSettings; } -export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: CloudZeroUpdateModalProps) { +interface UpdateValues { + api_key: string; + connection_id: string; + timezone: string; +} + +export default function CloudZeroUpdateModal({ + open, + onOk, + onCancel, + settings, +}: CloudZeroUpdateModalProps) { const { accessToken } = useAuthorized(); - const [form] = Form.useForm(); const updateMutation = useCloudZeroUpdateSettings(accessToken || ""); + const [showApiKey, setShowApiKey] = useState(false); + const form = useForm({ + defaultValues: { api_key: "", connection_id: "", timezone: "" }, + mode: "onSubmit", + }); useEffect(() => { if (open && settings) { - form.setFieldsValue({ - connection_id: settings.connection_id, + form.reset({ + connection_id: settings.connection_id ?? "", timezone: settings.timezone || "UTC", api_key: "", }); } else if (open) { - form.resetFields(); + form.reset({ api_key: "", connection_id: "", timezone: "" }); } - }, [open, settings, form]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, settings]); - 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 }), + const handleSubmit = form.handleSubmit((values) => { + updateMutation.mutate( + { + connection_id: values.connection_id, + timezone: values.timezone || "UTC", + ...(values.api_key && { api_key: values.api_key }), + }, + { + onSuccess: () => { + MessageManager.success( + "CloudZero integration updated successfully", + ); + form.reset({ api_key: "", connection_id: "", timezone: "" }); + onOk(); }, - { - onSuccess: () => { - MessageManager.success("CloudZero integration updated successfully"); - form.resetFields(); - onOk(); - }, - onError: (error: any) => { - if (error?.errorFields) { - return; - } - MessageManager.error(error?.message || "Failed to update CloudZero integration"); - }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError: (error: any) => { + MessageManager.error( + error?.message || "Failed to update CloudZero integration", + ); }, - ); - } catch (error: any) { - if (error?.errorFields) { - return; - } - MessageManager.error(error?.message || "Failed to update CloudZero integration"); - } - }; + }, + ); + }); const handleCancel = () => { - form.resetFields(); + form.reset({ api_key: "", connection_id: "", timezone: "" }); onCancel(); }; return ( - (!o ? handleCancel() : undefined)} > -
- - - - - - - - - -
-
+ + + Edit CloudZero Integration + + Update the CloudZero billing integration settings. + + + +
+
+ +
+ + +
+

+ Leave empty to keep the existing API key +

+
+ +
+ + + {form.formState.errors.connection_id && ( +

+ {form.formState.errors.connection_id.message as string} +

+ )} +
+ +
+ + +

+ Timezone for date handling (defaults to UTC if not provided) +

+
+ + + + + +
+
+
+ ); }