mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): migrate CloudZero cost-tracking to shadcn
- CloudZeroCostTracking: antd Card/Typography \u2192 shadcn Card. - CloudZeroCreateModal: antd Form/Modal/Input/Input.Password \u2192 shadcn Dialog/Input/Label + react-hook-form. Adds a show/hide-password toggle using lucide Eye/EyeOff (replaces antd Input.Password's built-in toggle). - CloudZeroEmptyPlaceholder: antd Empty/Typography/Button \u2192 plain Tailwind empty-state with shadcn Button + lucide Inbox icon. - CloudZeroIntegrationSettings: antd Alert/Card/Descriptions/Divider/ Popconfirm/Tag \u2192 shadcn Card + Badge + Separator + Alert + AlertDialog (for the dry-run / export confirmation flow). The descriptions list is rebuilt as a plain <dl> grid with bg-muted dt headers. - CloudZeroUpdateModal: same Dialog + react-hook-form + Eye/EyeOff treatment as the create modal. Connection_id null-coalesces to '' when resetting the form. Tests updated to match required-field label regex. Gates: TS \u2713 | Lint \u2713 | Vitest 6/6 \u2713 | Build \u2713. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
43524c1f62
commit
ad60a746f8
8 changed files with 480 additions and 288 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card>
|
||||
<Typography.Text>Loading CloudZero settings...</Typography.Text>
|
||||
<Card className="p-6">
|
||||
<span className="text-sm">Loading CloudZero settings...</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Text className="text-red-600">
|
||||
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
|
||||
</Typography.Text>
|
||||
<Card className="p-6">
|
||||
<span className="text-sm text-destructive">
|
||||
Error loading CloudZero settings:{" "}
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,7 +51,9 @@ export default function CloudZeroCostTracking() {
|
|||
if (!settings) {
|
||||
return (
|
||||
<>
|
||||
<CloudZeroEmptyPlaceholder startCreation={() => setIsCreateModalOpen(true)} />
|
||||
<CloudZeroEmptyPlaceholder
|
||||
startCreation={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
<CloudZeroCreationModal
|
||||
open={isCreateModalOpen}
|
||||
onOk={handleCreateModalOk}
|
||||
|
|
@ -57,8 +64,9 @@ export default function CloudZeroCostTracking() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CloudZeroIntegrationSettings settings={settings} onSettingsUpdated={handleCreateModalOk} />
|
||||
</>
|
||||
<CloudZeroIntegrationSettings
|
||||
settings={settings}
|
||||
onSettingsUpdated={handleCreateModalOk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CreateValues>({
|
||||
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 (
|
||||
<Modal
|
||||
title="Create CloudZero Integration"
|
||||
<Dialog
|
||||
open={open}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={createMutation.isPending}
|
||||
okText={createMutation.isPending ? "Creating..." : "Create"}
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{
|
||||
disabled: createMutation.isPending,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
disabled: createMutation.isPending,
|
||||
}}
|
||||
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="CloudZero API Key"
|
||||
name="api_key"
|
||||
rules={[{ required: true, message: "Please enter your CloudZero API key" }]}
|
||||
>
|
||||
<Input.Password placeholder="Enter your CloudZero API key" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Connection ID"
|
||||
name="connection_id"
|
||||
rules={[{ required: true, message: "Please enter your CloudZero connection ID" }]}
|
||||
>
|
||||
<Input placeholder="Enter your CloudZero connection ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
tooltip="Timezone for date handling (defaults to UTC if not provided)"
|
||||
>
|
||||
<Input placeholder="UTC" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create CloudZero Integration</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Configure a new CloudZero billing integration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_key">
|
||||
CloudZero API Key <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="api_key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
placeholder="Enter your CloudZero API key"
|
||||
{...form.register("api_key", {
|
||||
required: "Please enter your CloudZero API key",
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
aria-label={showApiKey ? "Hide api key" : "Show api key"}
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{form.formState.errors.api_key && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.api_key.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connection_id">
|
||||
Connection ID <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="connection_id"
|
||||
placeholder="Enter your CloudZero connection ID"
|
||||
{...form.register("connection_id", {
|
||||
required: "Please enter your CloudZero connection ID",
|
||||
})}
|
||||
/>
|
||||
{form.formState.errors.connection_id && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.connection_id.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Input
|
||||
id="timezone"
|
||||
placeholder="UTC"
|
||||
{...form.register("timezone")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Timezone for date handling (defaults to UTC if not provided)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center max-w-2xl mx-auto mt-8">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<Title level={4}>No CloudZero Integration Found</Title>
|
||||
<Paragraph type="secondary" className="max-w-md mx-auto">
|
||||
Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM.
|
||||
</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button type="primary" size="large" onClick={startCreation} className="flex items-center gap-2 mx-auto mt-4">
|
||||
<div className="bg-background p-12 rounded-lg border border-dashed border-border text-center max-w-2xl mx-auto mt-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Inbox className="h-12 w-12 text-muted-foreground" />
|
||||
<h4 className="text-lg font-semibold">
|
||||
No CloudZero Integration Found
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Connect your CloudZero account to start tracking and analyzing your
|
||||
cloud costs directly from LiteLLM.
|
||||
</p>
|
||||
<Button onClick={startCreation} className="mt-4">
|
||||
Add CloudZero Integration
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<div className="space-y-6 w-full max-w-4xl mx-auto">
|
||||
<Card
|
||||
title={
|
||||
<Card className="shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">CloudZero Configuration</span>
|
||||
<Tag color="success" className="ml-2 capitalize">
|
||||
<span className="text-lg font-semibold">
|
||||
CloudZero Configuration
|
||||
</span>
|
||||
<Badge variant="default" className="capitalize">
|
||||
{settings.status || "Active"}
|
||||
</Tag>
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<Edit size={16} />} onClick={handleEdit} className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
>
|
||||
<Edit size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={handleDeleteClick}
|
||||
className="flex items-center gap-2"
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
className="shadow-sm"
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xxl: 1,
|
||||
xl: 1,
|
||||
lg: 1,
|
||||
md: 1,
|
||||
sm: 1,
|
||||
xs: 1,
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label="API Key (Redacted)">
|
||||
<span className="font-mono text-gray-600">
|
||||
{settings.api_key_masked || <span className="text-gray-400 italic">Not configured</span>}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Connection ID">
|
||||
<span className="font-mono text-gray-600">
|
||||
{settings.connection_id || <span className="text-gray-400 italic">Not configured</span>}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Timezone">
|
||||
{settings.timezone || <span className="text-gray-400 italic">Default (UTC)</span>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
<Divider orientation="left" className="text-gray-500">
|
||||
<dl className="border border-border rounded-md overflow-hidden text-sm">
|
||||
<div className="grid grid-cols-[200px_1fr] border-b border-border">
|
||||
<dt className="bg-muted px-4 py-3 font-medium">
|
||||
API Key (Redacted)
|
||||
</dt>
|
||||
<dd className="px-4 py-3">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{settings.api_key_masked || (
|
||||
<span className="text-muted-foreground italic">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[200px_1fr] border-b border-border">
|
||||
<dt className="bg-muted px-4 py-3 font-medium">Connection ID</dt>
|
||||
<dd className="px-4 py-3">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{settings.connection_id || (
|
||||
<span className="text-muted-foreground italic">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[200px_1fr]">
|
||||
<dt className="bg-muted px-4 py-3 font-medium">Timezone</dt>
|
||||
<dd className="px-4 py-3">
|
||||
{settings.timezone || (
|
||||
<span className="text-muted-foreground italic">
|
||||
Default (UTC)
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Separator className="my-6" />
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Actions
|
||||
</Divider>
|
||||
</h4>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDryRun}
|
||||
loading={dryRunMutation.isPending}
|
||||
icon={<Play size={16} />}
|
||||
className="flex items-center gap-2"
|
||||
disabled={dryRunMutation.isPending}
|
||||
>
|
||||
Run Dry Run Simulation
|
||||
<Play size={16} />
|
||||
{dryRunMutation.isPending
|
||||
? "Running…"
|
||||
: "Run Dry Run Simulation"}
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title="Export Data to CloudZero"
|
||||
description="This will push the current accumulated cost data to CloudZero. Continue?"
|
||||
onConfirm={handleExport}
|
||||
okText="Export"
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={exportMutation.isPending}
|
||||
icon={<Upload size={16} />}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Export Data Now
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={exportMutation.isPending}>
|
||||
<Upload size={16} />
|
||||
{exportMutation.isPending
|
||||
? "Exporting…"
|
||||
: "Export Data Now"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Export Data to CloudZero
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will push the current accumulated cost data to
|
||||
CloudZero. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleExport}>
|
||||
Export
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{dryRunResult && (
|
||||
<div className="mt-6 animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<Alert
|
||||
message="Dry Run Results"
|
||||
description={
|
||||
<div className="mt-2">
|
||||
<p className="mb-2 text-gray-600">Simulation output for connection: {settings.connection_id}</p>
|
||||
<pre className="bg-gray-50 p-4 rounded-md border border-gray-200 overflow-x-auto text-xs font-mono text-gray-800">
|
||||
{dryRunResult}
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<CheckCircle className="text-blue-500" />}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertTitle>Dry Run Results</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2 text-muted-foreground">
|
||||
Simulation output for connection: {settings.connection_id}
|
||||
</p>
|
||||
<pre className="bg-muted p-4 rounded-md border border-border overflow-x-auto text-xs font-mono text-foreground">
|
||||
{dryRunResult}
|
||||
</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
@ -205,7 +235,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
<CloudZeroUpdateModal
|
||||
open={isEditModalOpen}
|
||||
onOk={handleEditModalOk}
|
||||
onCancel={handleEditModalCancel}
|
||||
onCancel={() => 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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<UpdateValues>({
|
||||
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 (
|
||||
<Modal
|
||||
title="Edit CloudZero Integration"
|
||||
<Dialog
|
||||
open={open}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={updateMutation.isPending}
|
||||
okText={updateMutation.isPending ? "Updating..." : "Update"}
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{
|
||||
disabled: updateMutation.isPending,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
disabled: updateMutation.isPending,
|
||||
}}
|
||||
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="CloudZero API Key"
|
||||
name="api_key"
|
||||
rules={[{ required: false, message: "Please enter your CloudZero API key" }]}
|
||||
tooltip="Leave empty to keep the existing API key"
|
||||
>
|
||||
<Input.Password placeholder="Leave empty to keep existing" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Connection ID"
|
||||
name="connection_id"
|
||||
rules={[{ required: true, message: "Please enter your CloudZero connection ID" }]}
|
||||
>
|
||||
<Input placeholder="Enter your CloudZero connection ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
tooltip="Timezone for date handling (defaults to UTC if not provided)"
|
||||
>
|
||||
<Input placeholder="UTC" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit CloudZero Integration</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Update the CloudZero billing integration settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_key">CloudZero API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="api_key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
placeholder="Leave empty to keep existing"
|
||||
{...form.register("api_key")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
aria-label={showApiKey ? "Hide api key" : "Show api key"}
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to keep the existing API key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connection_id">
|
||||
Connection ID <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="connection_id"
|
||||
placeholder="Enter your CloudZero connection ID"
|
||||
{...form.register("connection_id", {
|
||||
required: "Please enter your CloudZero connection ID",
|
||||
})}
|
||||
/>
|
||||
{form.formState.errors.connection_id && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.connection_id.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Input
|
||||
id="timezone"
|
||||
placeholder="UTC"
|
||||
{...form.register("timezone")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Timezone for date handling (defaults to UTC if not provided)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Updating…" : "Update"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue