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:
yuneng-jiang 2026-01-16 22:28:24 -08:00 • committed by GitHub
commit 480d9356af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1528 additions and 88 deletions

View file

@ -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",
},
},
);
});
});

View file

@ -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,
});
};

View file

@ -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,
});
};

View file

@ -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();
});

View file

@ -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}
/>
);
}

View file

@ -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();
});

View file

@ -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>
);
}

View file

@ -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();
});

View file

@ -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}
/>
);
}

View file

@ -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();
});

View file

@ -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>
);
}

View file

@ -14,6 +14,7 @@ export interface Team {
created_at: string;
keys: KeyResponse[];
members_with_roles: Member[];
spend: number;
}
export interface KeyResponse {

View file

@ -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 ||

View file

@ -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>