mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
UI Cloud Zero Integration
This commit is contained in:
parent
6595619906
commit
05fb750d7c
17 changed files with 1022 additions and 0 deletions
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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({}) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface CloudZeroSettings {
|
||||
api_key_masked: string;
|
||||
connection_id: string;
|
||||
timezone?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue