UI Cloud Zero Integration

This commit is contained in:
yuneng-jiang 2025-12-17 19:03:39 -08:00
parent 6595619906
commit 05fb750d7c
17 changed files with 1022 additions and 0 deletions

View file

@ -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<CreateResponse> => {
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<CreateResponse, Error, CreateParams>({
mutationFn: async (params: CreateParams) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return await performCloudZeroCreate(accessToken, params);
},
});
};

View file

@ -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<DryRunResponse> => {
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<DryRunResponse, Error, DryRunParams>({
mutationFn: async (params: DryRunParams = {}) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return await performCloudZeroDryRun(accessToken, params);
},
});
};

View file

@ -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<ExportResponse> => {
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<ExportResponse, Error, ExportParams>({
mutationFn: async (params: ExportParams = {}) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return await performCloudZeroExport(accessToken, params);
},
});
};

View file

@ -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<CloudZeroSettings | null> => {
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<CloudZeroSettings | null>({
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<UpdateResponse> => {
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<UpdateResponse, Error, UpdateParams>({
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({}) });
},
});
};

View file

@ -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(
<QueryClientProvider client={queryClient}>
<CloudZeroCostTracking />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText("No CloudZero Integration Found")).toBeInTheDocument();
});
});
});

View file

@ -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 (
<Card>
<Typography.Text>Loading CloudZero settings...</Typography.Text>
</Card>
);
}
if (error) {
return (
<Card>
<Typography.Text className="text-red-600">Error loading CloudZero settings: {error.message}</Typography.Text>
</Card>
);
}
if (!settings) {
return (
<>
<CloudZeroEmptyPlaceholder startCreation={() => setIsCreateModalOpen(true)} />
<CloudZeroCreationModal
open={isCreateModalOpen}
onOk={handleCreateModalOk}
onCancel={handleCreateModalCancel}
/>
</>
);
}
return (
<>
<CloudZeroIntegrationSettings settings={settings} onSettingsUpdated={handleCreateModalOk} />
</>
);
}

View file

@ -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(
<QueryClientProvider client={queryClient}>
<CloudZeroCreateModal open={true} onOk={vi.fn()} onCancel={vi.fn()} />
</QueryClientProvider>,
);
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();
});
});

View file

@ -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 (
<Modal
title="Create CloudZero Integration"
open={open}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={createMutation.isPending}
okText={createMutation.isPending ? "Creating..." : "Create"}
cancelText="Cancel"
okButtonProps={{
disabled: createMutation.isPending,
}}
cancelButtonProps={{
disabled: createMutation.isPending,
}}
>
<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>
);
}

View file

@ -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(<CloudZeroEmptyPlaceholder startCreation={startCreation} />);
expect(screen.getByText("No CloudZero Integration Found")).toBeInTheDocument();
expect(screen.getByText(/Connect your CloudZero account/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create Integration" })).toBeInTheDocument();
});
});

View file

@ -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 (
<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">
Create Integration
</Button>
</Empty>
</div>
);
}

View file

@ -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(
<QueryClientProvider client={queryClient}>
<CloudZeroIntegrationSettings settings={mockSettings} onSettingsUpdated={vi.fn()} />
</QueryClientProvider>,
);
expect(screen.getByText("CloudZero Configuration")).toBeInTheDocument();
expect(screen.getByText("API Key (Redacted)")).toBeInTheDocument();
expect(screen.getByText("Connection ID")).toBeInTheDocument();
expect(screen.getByText("Timezone")).toBeInTheDocument();
});
});

View file

@ -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 (
<>
<div className="space-y-6 w-full max-w-4xl mx-auto">
<Card
title={
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">CloudZero Configuration</span>
<Tag color="success" className="ml-2 capitalize">
{settings.status || "Active"}
</Tag>
</div>
}
extra={
<div className="flex gap-2">
<Button icon={<Edit size={16} />} onClick={handleEdit} className="flex items-center gap-2">
Edit
</Button>
<Popconfirm
title="Delete Integration"
description="Delete functionality is not yet available in the API. This button is disabled."
okText="OK"
cancelText="Cancel"
okButtonProps={{
danger: true,
}}
>
<Button danger icon={<Trash2 size={16} />} disabled className="flex items-center gap-2">
Delete
</Button>
</Popconfirm>
</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>
</Descriptions.Item>
<Descriptions.Item label="Connection ID">
<span className="font-mono text-gray-600">{settings.connection_id}</span>
</Descriptions.Item>
<Descriptions.Item label="Timezone">
{settings.timezone || <span className="text-gray-400 italic">Default (UTC)</span>}
</Descriptions.Item>
</Descriptions>
<Divider orientation="left" className="text-gray-500">
Actions
</Divider>
<div className="flex flex-wrap gap-4 mb-6">
<Button
onClick={handleDryRun}
loading={dryRunMutation.isPending}
icon={<Play size={16} />}
className="flex items-center gap-2"
>
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>
</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>
)}
</Card>
</div>
<CloudZeroUpdateModal
open={isEditModalOpen}
onOk={handleEditModalOk}
onCancel={handleEditModalCancel}
settings={settings}
/>
</>
);
}

View file

@ -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(
<QueryClientProvider client={queryClient}>
<CloudZeroUpdateModal open={true} onOk={vi.fn()} onCancel={vi.fn()} settings={mockSettings} />
</QueryClientProvider>,
);
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();
});
});

View file

@ -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 (
<Modal
title="Edit CloudZero Integration"
open={open}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={updateMutation.isPending}
okText={updateMutation.isPending ? "Updating..." : "Update"}
cancelText="Cancel"
okButtonProps={{
disabled: updateMutation.isPending,
}}
cancelButtonProps={{
disabled: updateMutation.isPending,
}}
>
<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>
);
}

View file

@ -0,0 +1,6 @@
export interface CloudZeroSettings {
api_key_masked: string;
connection_id: string;
timezone?: string;
status?: string;
}

View file

@ -33,6 +33,11 @@ vi.mock("./email_settings", () => ({
default: () => <div>Mock Email Settings</div>,
}));
vi.mock("./CloudZeroCostTracking/CloudZeroCostTracking", () => ({
__esModule: true,
default: () => <div>Mock CloudZero Cost Tracking</div>,
}));
// 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(<Settings {...defaultProps} />);
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(<Settings {...defaultProps} />);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument();
});
});

View file

@ -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<SettingsPageProps> = ({ accessToken, userRole, userID,
<TabGroup>
<TabList variant="line" defaultValue="1">
<Tab value="1">Logging Callbacks</Tab>
<Tab value="2">CloudZero Cost Tracking</Tab>
<Tab value="2">Alerting Types</Tab>
<Tab value="3">Alerting Settings</Tab>
<Tab value="4">Email Alerts</Tab>
@ -593,6 +595,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
}}
/>
</TabPanel>
<TabPanel>
<div className="p-8">
<CloudZeroCostTracking />
</div>
</TabPanel>
<TabPanel>
<Card>
<Text className="my-2">