mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge pull request #19268 from BerriAI/litellm_ui_deleted_keys_teams_table
[Feature] UI - Logs: Deleted Keys and Teams Table
This commit is contained in:
commit
480d9356af
14 changed files with 1528 additions and 88 deletions
|
|
@ -3,14 +3,32 @@ import { renderHook, waitFor } from "@testing-library/react";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useKeys } from "./useKeys";
|
||||
import { keyListCall } from "@/components/networking";
|
||||
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
// Mock the networking function
|
||||
// Mock the networking utilities
|
||||
vi.mock("@/components/networking", () => ({
|
||||
keyListCall: vi.fn(),
|
||||
getProxyBaseUrl: vi.fn().mockReturnValue(""),
|
||||
getGlobalLitellmHeaderName: vi.fn().mockReturnValue("Authorization"),
|
||||
deriveErrorMessage: vi.fn((errorData: any) => {
|
||||
return (
|
||||
(errorData?.error && (errorData.error.message || errorData.error)) ||
|
||||
errorData?.message ||
|
||||
errorData?.detail ||
|
||||
errorData?.error ||
|
||||
JSON.stringify(errorData)
|
||||
);
|
||||
}),
|
||||
handleError: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Mock console methods to avoid noise in tests
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Mock useAuthorized hook - we can override this in individual tests
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
|
|
@ -164,6 +182,9 @@ describe("useKeys", () => {
|
|||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
|
||||
// Reset fetch mock
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
|
|
@ -171,7 +192,10 @@ describe("useKeys", () => {
|
|||
|
||||
it("should return keys data when query is successful", async () => {
|
||||
// Mock successful API call
|
||||
(keyListCall as any).mockResolvedValue(mockKeysResponse);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockKeysResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useKeys(1, 10), { wrapper });
|
||||
|
||||
|
|
@ -187,25 +211,28 @@ describe("useKeys", () => {
|
|||
|
||||
expect(result.current.data).toEqual(mockKeysResponse);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(keyListCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
null, // organizationID
|
||||
null, // teamID
|
||||
null, // selectedKeyAlias
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
1, // page
|
||||
10, // pageSize
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/key/list?page=1&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer test-access-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(keyListCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle error when keyListCall fails", async () => {
|
||||
const errorMessage = "Failed to fetch keys";
|
||||
const testError = new Error(errorMessage);
|
||||
const errorResponse = { error: errorMessage };
|
||||
|
||||
// Mock failed API call
|
||||
(keyListCall as any).mockRejectedValue(testError);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => errorResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useKeys(1, 10), { wrapper });
|
||||
|
||||
|
|
@ -218,19 +245,20 @@ describe("useKeys", () => {
|
|||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toEqual(testError);
|
||||
expect(result.current.error).toBeDefined();
|
||||
expect(result.current.error?.message).toBe(errorMessage);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(keyListCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
null, // organizationID
|
||||
null, // teamID
|
||||
null, // selectedKeyAlias
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
1, // page
|
||||
10, // pageSize
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/key/list?page=1&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer test-access-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(keyListCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not execute query when accessToken is missing", async () => {
|
||||
|
|
@ -254,12 +282,15 @@ describe("useKeys", () => {
|
|||
expect(result.current.isFetched).toBe(false);
|
||||
|
||||
// API should not be called
|
||||
expect(keyListCall).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should pass correct page and pageSize parameters to the API", async () => {
|
||||
// Mock successful API call
|
||||
(keyListCall as any).mockResolvedValue(mockKeysResponse);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockKeysResponse,
|
||||
});
|
||||
|
||||
const page = 2;
|
||||
const pageSize = 20;
|
||||
|
|
@ -271,15 +302,15 @@ describe("useKeys", () => {
|
|||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(keyListCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
null, // organizationID
|
||||
null, // teamID
|
||||
null, // selectedKeyAlias
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
page, // page
|
||||
pageSize, // pageSize
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`/key/list?page=${page}&size=${pageSize}&return_full_object=true&include_team_keys=true&include_created_by_keys=true`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer test-access-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -291,7 +322,10 @@ describe("useKeys", () => {
|
|||
current_page: 1,
|
||||
total_pages: 0,
|
||||
};
|
||||
(keyListCall as any).mockResolvedValue(emptyResponse);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => emptyResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useKeys(1, 10), { wrapper });
|
||||
|
||||
|
|
@ -302,15 +336,15 @@ describe("useKeys", () => {
|
|||
});
|
||||
|
||||
expect(result.current.data).toEqual(emptyResponse);
|
||||
expect(keyListCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
null, // organizationID
|
||||
null, // teamID
|
||||
null, // selectedKeyAlias
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
1, // page
|
||||
10, // pageSize
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/key/list?page=1&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer test-access-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -318,7 +352,7 @@ describe("useKeys", () => {
|
|||
const timeoutError = new Error("Network timeout");
|
||||
|
||||
// Mock network timeout
|
||||
(keyListCall as any).mockRejectedValue(timeoutError);
|
||||
mockFetch.mockRejectedValueOnce(timeoutError);
|
||||
|
||||
const { result } = renderHook(() => useKeys(1, 10), { wrapper });
|
||||
|
||||
|
|
@ -338,7 +372,10 @@ describe("useKeys", () => {
|
|||
current_page: 2,
|
||||
total_pages: 2,
|
||||
};
|
||||
(keyListCall as any).mockResolvedValue(paginatedResponse);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => paginatedResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useKeys(2, 10), { wrapper });
|
||||
|
||||
|
|
@ -348,15 +385,15 @@ describe("useKeys", () => {
|
|||
});
|
||||
|
||||
expect(result.current.data).toEqual(paginatedResponse);
|
||||
expect(keyListCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
null, // organizationID
|
||||
null, // teamID
|
||||
null, // selectedKeyAlias
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
2, // page
|
||||
10, // pageSize
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/key/list?page=2&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer test-access-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { keyListCall } from "@/components/networking";
|
||||
import {
|
||||
getProxyBaseUrl,
|
||||
getGlobalLitellmHeaderName,
|
||||
deriveErrorMessage,
|
||||
handleError,
|
||||
} from "@/components/networking";
|
||||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
|
|
@ -13,24 +18,114 @@ export interface KeysResponse {
|
|||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface DeletedKeyResponse extends KeyResponse {
|
||||
deleted_at: string;
|
||||
deleted_by: string;
|
||||
}
|
||||
|
||||
export interface DeletedKeysResponse {
|
||||
keys: DeletedKeyResponse[];
|
||||
total_count: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface KeyListCallOptions {
|
||||
organizationID?: string | null;
|
||||
teamID?: string | null;
|
||||
selectedKeyAlias?: string | null;
|
||||
userID?: string | null;
|
||||
keyHash?: string | null;
|
||||
sortBy?: string | null;
|
||||
sortOrder?: string | null;
|
||||
expand?: string | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
const keyListCall = async (
|
||||
accessToken: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: KeyListCallOptions = {},
|
||||
) => {
|
||||
/**
|
||||
* Get all available keys on proxy
|
||||
*/
|
||||
try {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({
|
||||
team_id: options.teamID,
|
||||
organization_id: options.organizationID,
|
||||
key_alias: options.selectedKeyAlias,
|
||||
key_hash: options.keyHash,
|
||||
user_id: options.userID,
|
||||
page,
|
||||
size: pageSize,
|
||||
sort_by: options.sortBy,
|
||||
sort_order: options.sortOrder,
|
||||
expand: options.expand,
|
||||
status: options.status,
|
||||
return_full_object: "true",
|
||||
include_team_keys: "true",
|
||||
include_created_by_keys: "true",
|
||||
})
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
|
||||
const url = `${baseUrl ? `${baseUrl}/key/list` : "/key/list"}?${params}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("/key/list API Response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to list keys:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const useKeys = (page: number, pageSize: number): UseQueryResult<KeysResponse> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<KeysResponse>({
|
||||
queryKey: keyKeys.list({ page, limit: pageSize }),
|
||||
queryFn: async () =>
|
||||
await keyListCall(
|
||||
accessToken!,
|
||||
null, // organizationID
|
||||
null, // teamID
|
||||
null, // selectedKeyAlias
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
page,
|
||||
pageSize,
|
||||
),
|
||||
queryFn: async () => await keyListCall(accessToken!, page, pageSize),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 30000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
||||
export const deletedKeyKeys = createQueryKeys("deletedKeys");
|
||||
export const useDeletedKeys = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: KeyListCallOptions = {},
|
||||
): UseQueryResult<KeysResponse> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<KeysResponse>({
|
||||
queryKey: deletedKeyKeys.list({ page, limit: pageSize, ...options }),
|
||||
queryFn: async () => await keyListCall(accessToken!, page, pageSize, { ...options, status: "deleted" }),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 30000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,9 +1,93 @@
|
|||
import { useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchTeams } from "@/app/(dashboard)/networking";
|
||||
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
|
||||
import { teamInfoCall } from "@/components/networking";
|
||||
import {
|
||||
getProxyBaseUrl,
|
||||
getGlobalLitellmHeaderName,
|
||||
deriveErrorMessage,
|
||||
handleError,
|
||||
} from "@/components/networking";
|
||||
|
||||
export interface TeamsResponse {
|
||||
teams: Team[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface DeletedTeam extends Team {
|
||||
deleted_at: string;
|
||||
deleted_by: string;
|
||||
}
|
||||
|
||||
|
||||
export interface TeamListCallOptions {
|
||||
organizationID?: string | null;
|
||||
teamID?: string | null;
|
||||
team_alias?: string | null;
|
||||
userID?: string | null;
|
||||
sortBy?: string | null;
|
||||
sortOrder?: string | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
const teamListCall = async (
|
||||
accessToken: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: TeamListCallOptions = {},
|
||||
) => {
|
||||
/**
|
||||
* Get all available teams on proxy
|
||||
*/
|
||||
try {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({
|
||||
team_id: options.teamID,
|
||||
organization_id: options.organizationID,
|
||||
team_alias: options.team_alias,
|
||||
user_id: options.userID,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort_by: options.sortBy,
|
||||
sort_order: options.sortOrder,
|
||||
status: options.status,
|
||||
})
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
|
||||
const url = `${baseUrl ? `${baseUrl}/v2/team/list` : "/v2/team/list"}?${params}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("/v2/team/list API Response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to list teams:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const teamKeys = createQueryKeys("teams");
|
||||
export const useTeams = (): UseQueryResult<Team[]> => {
|
||||
|
|
@ -39,3 +123,80 @@ export const useTeam = (teamId?: string) => {
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deletedTeamListCall = async (
|
||||
accessToken: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: TeamListCallOptions = {},
|
||||
) => {
|
||||
/**
|
||||
* Get deleted teams from proxy
|
||||
*/
|
||||
try {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({
|
||||
team_id: options.teamID,
|
||||
organization_id: options.organizationID,
|
||||
team_alias: options.team_alias,
|
||||
user_id: options.userID,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort_by: options.sortBy,
|
||||
sort_order: options.sortOrder,
|
||||
status: "deleted",
|
||||
})
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
|
||||
const url = `${baseUrl ? `${baseUrl}/team/list` : "/team/list"}?${params}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("/team/list?status=deleted API Response:", data);
|
||||
|
||||
// Extract teams array from response if it's wrapped in a response object
|
||||
// Otherwise return the data directly if it's already an array
|
||||
if (data && typeof data === 'object' && 'teams' in data) {
|
||||
return data.teams as DeletedTeam[];
|
||||
}
|
||||
return data as DeletedTeam[];
|
||||
} catch (error) {
|
||||
console.error("Failed to list deleted teams:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deletedTeamKeys = createQueryKeys("deletedTeams");
|
||||
export const useDeletedTeams = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: TeamListCallOptions = {},
|
||||
): UseQueryResult<DeletedTeam[]> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<DeletedTeam[]>({
|
||||
queryKey: deletedTeamKeys.list({ page, limit: pageSize, ...options }),
|
||||
queryFn: async () => await deletedTeamListCall(accessToken!, page, pageSize, options),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 30000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { vi, it, expect, beforeEach, MockedFunction } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import DeletedKeysPage from "./DeletedKeysPage";
|
||||
import { useDeletedKeys, DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
|
||||
useDeletedKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseDeletedKeys = useDeletedKeys as MockedFunction<typeof useDeletedKeys>;
|
||||
|
||||
const mockDeletedKey: DeletedKeyResponse = {
|
||||
token: "sk-1234567890abcdef",
|
||||
token_id: "key-1",
|
||||
key_name: "test-key",
|
||||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
max_budget: 100,
|
||||
expires: "2024-12-31T23:59:59Z",
|
||||
models: ["gpt-3.5-turbo"],
|
||||
aliases: {},
|
||||
config: {},
|
||||
user_id: "user-1",
|
||||
team_id: "team-1",
|
||||
max_parallel_requests: 10,
|
||||
metadata: {},
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 100,
|
||||
duration: "30d",
|
||||
budget_duration: "1m",
|
||||
budget_reset_at: "2024-12-01T00:00:00Z",
|
||||
allowed_cache_controls: [],
|
||||
allowed_routes: [],
|
||||
permissions: {},
|
||||
model_spend: {},
|
||||
model_max_budget: {},
|
||||
soft_budget_cooldown: false,
|
||||
blocked: false,
|
||||
litellm_budget_table: {},
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-11-01T10:00:00Z",
|
||||
updated_at: "2024-11-15T10:00:00Z",
|
||||
team_spend: 5.5,
|
||||
team_alias: "Test Team",
|
||||
team_tpm_limit: 5000,
|
||||
team_rpm_limit: 500,
|
||||
team_max_budget: 500,
|
||||
team_models: ["gpt-3.5-turbo"],
|
||||
team_blocked: false,
|
||||
soft_budget: 50,
|
||||
team_model_aliases: {},
|
||||
team_member_spend: 0,
|
||||
team_metadata: {},
|
||||
end_user_id: "end-user-1",
|
||||
end_user_tpm_limit: 100,
|
||||
end_user_rpm_limit: 10,
|
||||
end_user_max_budget: 10,
|
||||
last_refreshed_at: Date.now(),
|
||||
api_key: "sk-1234567890abcdef",
|
||||
user_role: "user",
|
||||
rpm_limit_per_model: {},
|
||||
tpm_limit_per_model: {},
|
||||
user_tpm_limit: 1000,
|
||||
user_rpm_limit: 100,
|
||||
user_email: "user@example.com",
|
||||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseDeletedKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [mockDeletedKey],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
},
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render DeletedKeysPage component", () => {
|
||||
renderWithProviders(<DeletedKeysPage />);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle loading state", () => {
|
||||
mockUseDeletedKeys.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<DeletedKeysPage />);
|
||||
|
||||
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useDeletedKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { DeletedKeysTable } from "./DeletedKeysTable/DeletedKeysTable";
|
||||
|
||||
export default function DeletedKeysPage() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize] = useState(50);
|
||||
|
||||
const {
|
||||
data: keysData,
|
||||
isPending: isLoading,
|
||||
isFetching,
|
||||
} = useDeletedKeys(pageIndex + 1, pageSize);
|
||||
|
||||
return (
|
||||
<DeletedKeysTable
|
||||
keys={keysData?.keys || []}
|
||||
totalCount={keysData?.total_count || 0}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPageIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { vi, it, expect, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import { DeletedKeysTable } from "./DeletedKeysTable";
|
||||
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
||||
const mockDeletedKey: DeletedKeyResponse = {
|
||||
token: "sk-1234567890abcdef",
|
||||
token_id: "key-1",
|
||||
key_name: "test-key",
|
||||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
max_budget: 100,
|
||||
expires: "2024-12-31T23:59:59Z",
|
||||
models: ["gpt-3.5-turbo"],
|
||||
aliases: {},
|
||||
config: {},
|
||||
user_id: "user-1",
|
||||
team_id: "team-1",
|
||||
max_parallel_requests: 10,
|
||||
metadata: {},
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 100,
|
||||
duration: "30d",
|
||||
budget_duration: "1m",
|
||||
budget_reset_at: "2024-12-01T00:00:00Z",
|
||||
allowed_cache_controls: [],
|
||||
allowed_routes: [],
|
||||
permissions: {},
|
||||
model_spend: {},
|
||||
model_max_budget: {},
|
||||
soft_budget_cooldown: false,
|
||||
blocked: false,
|
||||
litellm_budget_table: {},
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-11-01T10:00:00Z",
|
||||
updated_at: "2024-11-15T10:00:00Z",
|
||||
team_spend: 5.5,
|
||||
team_alias: "Test Team",
|
||||
team_tpm_limit: 5000,
|
||||
team_rpm_limit: 500,
|
||||
team_max_budget: 500,
|
||||
team_models: ["gpt-3.5-turbo"],
|
||||
team_blocked: false,
|
||||
soft_budget: 50,
|
||||
team_model_aliases: {},
|
||||
team_member_spend: 0,
|
||||
team_metadata: {},
|
||||
end_user_id: "end-user-1",
|
||||
end_user_tpm_limit: 100,
|
||||
end_user_rpm_limit: 10,
|
||||
end_user_max_budget: 10,
|
||||
last_refreshed_at: Date.now(),
|
||||
api_key: "sk-1234567890abcdef",
|
||||
user_role: "user",
|
||||
rpm_limit_per_model: {},
|
||||
tpm_limit_per_model: {},
|
||||
user_tpm_limit: 1000,
|
||||
user_rpm_limit: 100,
|
||||
user_email: "user@example.com",
|
||||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render DeletedKeysTable component", () => {
|
||||
renderWithProviders(
|
||||
<DeletedKeysTable
|
||||
keys={[mockDeletedKey]}
|
||||
totalCount={1}
|
||||
isLoading={false}
|
||||
isFetching={false}
|
||||
pageIndex={0}
|
||||
pageSize={50}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display key information correctly", () => {
|
||||
renderWithProviders(
|
||||
<DeletedKeysTable
|
||||
keys={[mockDeletedKey]}
|
||||
totalCount={1}
|
||||
isLoading={false}
|
||||
isFetching={false}
|
||||
pageIndex={0}
|
||||
pageSize={50}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("sk-1234567890abcdef")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
"use client";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { KeyResponse } from "../../key_team_helpers/key_list";
|
||||
|
||||
interface DeletedKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
}
|
||||
|
||||
export function DeletedKeysTable({
|
||||
keys,
|
||||
totalCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
}: DeletedKeysTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "deleted_at",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const [tablePagination, setTablePagination] = useState<PaginationState>({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// Sync pagination state when prop changes
|
||||
React.useEffect(() => {
|
||||
setTablePagination({ pageIndex, pageSize });
|
||||
}, [pageIndex, pageSize]);
|
||||
|
||||
const columns: ColumnDef<KeyResponse>[] = [
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
header: "Key ID",
|
||||
size: 150,
|
||||
maxSize: 250,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-blue-500 text-xs truncate block max-w-[250px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
header: "Key Alias",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-xs truncate block max-w-[200px]">
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
header: "Team Alias",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<span className="truncate block max-w-[180px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
maxSize: 140,
|
||||
cell: (info) => (
|
||||
<span className="block max-w-[140px]">
|
||||
{formatNumberWithCommas(info.getValue() as number, 4)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
maxSize: 150,
|
||||
cell: (info) => {
|
||||
const maxBudget = info.getValue() as number | null;
|
||||
return (
|
||||
<span className="block max-w-[150px]">
|
||||
{maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user_email",
|
||||
header: "User Email",
|
||||
size: 160,
|
||||
maxSize: 250,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-xs truncate block max-w-[250px]">
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
header: "User ID",
|
||||
size: 120,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
return (
|
||||
<Tooltip title={userId || undefined}>
|
||||
<span className="truncate block max-w-[200px]">
|
||||
{userId || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
header: "Created At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return (
|
||||
<span className="block max-w-[140px]">
|
||||
{value ? new Date(value as string).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
header: "Created By",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).created_by as string | null | undefined;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[180px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
header: "Deleted At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_at as string | null | undefined;
|
||||
return (
|
||||
<span className="block max-w-[140px]">
|
||||
{value ? new Date(value).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
header: "Deleted By",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_by as string | null | undefined;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[180px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: keys,
|
||||
columns,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
state: {
|
||||
sorting,
|
||||
pagination: tablePagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (updater) => {
|
||||
const newPagination = typeof updater === "function" ? updater(tablePagination) : updater;
|
||||
setTablePagination(newPagination);
|
||||
onPageChange(newPagination.pageIndex);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
enableSorting: true,
|
||||
manualSorting: false,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(totalCount / pageSize),
|
||||
});
|
||||
|
||||
const { pageIndex: currentPageIndex } = table.getState().pagination;
|
||||
const start = currentPageIndex * pageSize + 1;
|
||||
const end = Math.min((currentPageIndex + 1) * pageSize, totalCount);
|
||||
const rangeLabel = `${start} - ${end}`;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<div className="border-b py-4 flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between w-full mb-4">
|
||||
{isLoading || isFetching ? (
|
||||
<span className="inline-flex text-sm text-gray-700">Loading...</span>
|
||||
) : (
|
||||
<span className="inline-flex text-sm text-gray-700">
|
||||
Showing {rangeLabel} of {totalCount} results
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{isLoading || isFetching ? (
|
||||
<span className="text-sm text-gray-700">Loading...</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {currentPageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={isLoading || isFetching || !table.getCanNextPage()}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[75vh] overflow-auto">
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
data-header-id={header.id}
|
||||
className={`py-1 h-8 relative hover:bg-gray-50`}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
maxWidth: header.column.columnDef.maxSize,
|
||||
position: "relative",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer) {
|
||||
(resizer as HTMLElement).style.opacity = "0.5";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer && !header.column.getIsResizing()) {
|
||||
(resizer as HTMLElement).style.opacity = "0";
|
||||
}
|
||||
}}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: "5px",
|
||||
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
|
||||
cursor: "col-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
opacity: header.column.getIsResizing() ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading || isFetching ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading keys...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : keys.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
maxWidth: cell.column.columnDef.maxSize,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No deleted keys found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { vi, it, expect, beforeEach, MockedFunction } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import DeletedTeamsPage from "./DeletedTeamsPage";
|
||||
import { useDeletedTeams, DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useDeletedTeams: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseDeletedTeams = useDeletedTeams as MockedFunction<typeof useDeletedTeams>;
|
||||
|
||||
const mockDeletedTeam: DeletedTeam = {
|
||||
team_id: "team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-3.5-turbo", "gpt-4"],
|
||||
max_budget: 500,
|
||||
budget_duration: "1m",
|
||||
tpm_limit: 5000,
|
||||
rpm_limit: 500,
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-10-01T10:00:00Z",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
spend: 100.5,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseDeletedTeams.mockReturnValue({
|
||||
data: [mockDeletedTeam],
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render DeletedTeamsPage component", () => {
|
||||
renderWithProviders(<DeletedTeamsPage />);
|
||||
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle loading state", () => {
|
||||
mockUseDeletedTeams.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<DeletedTeamsPage />);
|
||||
|
||||
expect(screen.getByText("🚅 Loading teams...")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
import { useDeletedTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable";
|
||||
|
||||
export default function DeletedTeamsPage() {
|
||||
const {
|
||||
data: teamsData,
|
||||
isPending: isLoading,
|
||||
isFetching,
|
||||
} = useDeletedTeams(1, 100);
|
||||
|
||||
return (
|
||||
<DeletedTeamsTable
|
||||
teams={teamsData || []}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { vi, it, expect, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import { DeletedTeamsTable } from "./DeletedTeamsTable";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
const mockDeletedTeam: DeletedTeam = {
|
||||
team_id: "team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-3.5-turbo", "gpt-4"],
|
||||
max_budget: 500,
|
||||
budget_duration: "1m",
|
||||
tpm_limit: 5000,
|
||||
rpm_limit: 500,
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-10-01T10:00:00Z",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
spend: 100.5,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render DeletedTeamsTable component", () => {
|
||||
renderWithProviders(
|
||||
<DeletedTeamsTable teams={[mockDeletedTeam]} isLoading={false} isFetching={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display team information correctly", () => {
|
||||
renderWithProviders(
|
||||
<DeletedTeamsTable teams={[mockDeletedTeam]} isLoading={false} isFetching={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 team")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
"use client";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Badge,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
|
||||
interface DeletedTeamsTableProps {
|
||||
teams: DeletedTeam[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export function DeletedTeamsTable({
|
||||
teams,
|
||||
isLoading,
|
||||
isFetching,
|
||||
}: DeletedTeamsTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "deleted_at",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const columns: ColumnDef<DeletedTeam>[] = [
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
header: "Team Name",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[200px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
header: "Team ID",
|
||||
size: 150,
|
||||
maxSize: 250,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-blue-500 text-xs truncate block max-w-[250px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
header: "Created",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return (
|
||||
<span className="block max-w-[140px]">
|
||||
{value ? new Date(value as string).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const spend = (info.row.original as any).spend as number | undefined;
|
||||
return (
|
||||
<span className="block max-w-[140px]">
|
||||
{spend !== undefined ? formatNumberWithCommas(spend, 4) : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
maxSize: 150,
|
||||
cell: (info) => {
|
||||
const maxBudget = info.getValue() as number | null;
|
||||
return (
|
||||
<span className="block max-w-[150px]">
|
||||
{maxBudget === null || maxBudget === undefined ? "No limit" : `$${formatNumberWithCommas(maxBudget)}`}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
header: "Models",
|
||||
size: 200,
|
||||
maxSize: 300,
|
||||
cell: (info) => {
|
||||
const models = info.getValue() as string[];
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return (
|
||||
<Badge size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-[300px]">
|
||||
{models.slice(0, 3).map((model: string, index: number) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{models.length > 3 && (
|
||||
<Badge size={"xs"} color="gray">
|
||||
<Text>
|
||||
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "organization_id",
|
||||
accessorKey: "organization_id",
|
||||
header: "Organization",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[200px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
header: "Deleted At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_at as string | null | undefined;
|
||||
return (
|
||||
<span className="block max-w-[140px]">
|
||||
{value ? new Date(value).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
header: "Deleted By",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_by as string | null | undefined;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[180px]">
|
||||
{value || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: teams,
|
||||
columns,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
manualSorting: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<div className="border-b py-4 flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between w-full mb-4">
|
||||
{isLoading || isFetching ? (
|
||||
<span className="inline-flex text-sm text-gray-700">Loading...</span>
|
||||
) : (
|
||||
<span className="inline-flex text-sm text-gray-700">
|
||||
Showing {teams.length} {teams.length === 1 ? "team" : "teams"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[75vh] overflow-auto">
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
data-header-id={header.id}
|
||||
className={`py-1 h-8 relative hover:bg-gray-50`}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
maxWidth: header.column.columnDef.maxSize,
|
||||
position: "relative",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer) {
|
||||
(resizer as HTMLElement).style.opacity = "0.5";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer && !header.column.getIsResizing()) {
|
||||
(resizer as HTMLElement).style.opacity = "0";
|
||||
}
|
||||
}}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: "5px",
|
||||
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
|
||||
cursor: "col-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
opacity: header.column.getIsResizing() ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading || isFetching ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading teams...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : teams.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
maxWidth: cell.column.columnDef.maxSize,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No deleted teams found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export interface Team {
|
|||
created_at: string;
|
||||
keys: KeyResponse[];
|
||||
members_with_roles: Member[];
|
||||
spend: number;
|
||||
}
|
||||
|
||||
export interface KeyResponse {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ const defaultServerRootPath = "/";
|
|||
export let serverRootPath = defaultServerRootPath;
|
||||
export let proxyBaseUrl = defaultProxyBaseUrl;
|
||||
if (isLocal != true) {
|
||||
console.log = function () {};
|
||||
console.log = function () { };
|
||||
}
|
||||
|
||||
const getWindowLocation = () => {
|
||||
|
|
@ -239,7 +239,7 @@ export interface CredentialsResponse {
|
|||
|
||||
let lastErrorTime = 0;
|
||||
|
||||
const handleError = async (errorData: string | any) => {
|
||||
export const handleError = async (errorData: string | any) => {
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastErrorTime > 60000) {
|
||||
// 60000 milliseconds = 60 seconds
|
||||
|
|
@ -310,6 +310,11 @@ export function setGlobalLitellmHeaderName(headerName: string = "Authorization")
|
|||
globalLitellmHeaderName = headerName;
|
||||
}
|
||||
|
||||
// Function to get the global header name
|
||||
export function getGlobalLitellmHeaderName(): string {
|
||||
return globalLitellmHeaderName;
|
||||
}
|
||||
|
||||
export const makeModelGroupPublic = async (accessToken: string, modelGroups: string[]) => {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/model_group/make_public` : `/model_group/make_public`;
|
||||
const response = await fetch(url, {
|
||||
|
|
@ -3270,6 +3275,7 @@ export const keyListCall = async (
|
|||
sortBy: string | null = null,
|
||||
sortOrder: string | null = null,
|
||||
expand: string | null = null,
|
||||
status: string | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get all available teams on proxy
|
||||
|
|
@ -3319,6 +3325,10 @@ export const keyListCall = async (
|
|||
queryParams.append("expand", expand);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryParams.append("status", status);
|
||||
}
|
||||
|
||||
queryParams.append("return_full_object", "true");
|
||||
queryParams.append("include_team_keys", "true");
|
||||
queryParams.append("include_created_by_keys", "true");
|
||||
|
|
@ -8156,7 +8166,7 @@ export const perUserAnalyticsCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
const deriveErrorMessage = (errorData: any): string => {
|
||||
export const deriveErrorMessage = (errorData: any): string => {
|
||||
return (
|
||||
(errorData?.error && (errorData.error.message || errorData.error)) ||
|
||||
errorData?.message ||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import AuditLogs from "./audit_logs";
|
|||
import { getTimeRangeDisplay } from "./logs_utils";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { truncateString } from "@/utils/textUtils";
|
||||
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
|
||||
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
|
||||
|
||||
interface SpendLogsTableProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -355,7 +357,7 @@ export default function SpendLogsTable({
|
|||
sessionLogs.data?.data?.map((log) => ({
|
||||
...log,
|
||||
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
|
||||
onSessionClick: (sessionId: string) => {},
|
||||
onSessionClick: (sessionId: string) => { },
|
||||
})) || [];
|
||||
|
||||
// Add this function to handle manual refresh
|
||||
|
|
@ -502,6 +504,8 @@ export default function SpendLogsTable({
|
|||
<TabList>
|
||||
<Tab>Request Logs</Tab>
|
||||
<Tab>Audit Logs</Tab>
|
||||
<Tab>Deleted Keys</Tab>
|
||||
<Tab>Deleted Teams</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
|
|
@ -537,7 +541,7 @@ export default function SpendLogsTable({
|
|||
data={sessionData}
|
||||
renderSubComponent={RequestViewer}
|
||||
getRowCanExpand={() => true}
|
||||
// Optionally: add session-specific row expansion state
|
||||
// Optionally: add session-specific row expansion state
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -597,9 +601,8 @@ export default function SpendLogsTable({
|
|||
{quickSelectOptions.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${
|
||||
displayLabel === option.label ? "bg-blue-50 text-blue-600" : ""
|
||||
}`}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${displayLabel === option.label ? "bg-blue-50 text-blue-600" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
|
||||
setStartTime(
|
||||
|
|
@ -617,9 +620,8 @@ export default function SpendLogsTable({
|
|||
))}
|
||||
<div className="border-t my-2" />
|
||||
<button
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${
|
||||
isCustomDate ? "bg-blue-50 text-blue-600" : ""
|
||||
}`}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${isCustomDate ? "bg-blue-50 text-blue-600" : ""
|
||||
}`}
|
||||
onClick={() => setIsCustomDate(!isCustomDate)}
|
||||
>
|
||||
Custom Range
|
||||
|
|
@ -749,6 +751,8 @@ export default function SpendLogsTable({
|
|||
allTeams={allTeams}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel><DeletedKeysPage /></TabPanel>
|
||||
<TabPanel><DeletedTeamsPage /></TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
|
|
@ -932,11 +936,10 @@ export function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
|||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Status:</span>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block text-center w-16 ${
|
||||
(row.original.metadata?.status || "Success").toLowerCase() !== "failure"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block text-center w-16 ${(row.original.metadata?.status || "Success").toLowerCase() !== "failure"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{(row.original.metadata?.status || "Success").toLowerCase() !== "failure" ? "Success" : "Failure"}
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue