diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx
index 4fd513b0d2d..d7322d6d760 100644
--- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx
@@ -262,8 +262,8 @@ it("should display user email correctly", async () => {
});
});
-it("should show skeleton loaders when isLoading is true", () => {
- // Mock loading state
+it("should show loading message only on initial load (isPending)", () => {
+ // Mock initial loading state
mockUseKeys.mockReturnValue({
data: null,
isPending: true,
@@ -283,7 +283,7 @@ it("should show skeleton loaders when isLoading is true", () => {
renderWithProviders();
- // Check that loading message is shown
+ // Check that loading message is shown on initial load
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
// Check that actual key data is not shown
@@ -795,3 +795,63 @@ describe("pagination display – total count and page count", () => {
});
});
});
+
+describe("refetch button", () => {
+ it("should show Fetch button in normal state", () => {
+ renderWithProviders();
+
+ const fetchButton = screen.getByTitle("Fetch data");
+ expect(fetchButton).toBeInTheDocument();
+ expect(fetchButton).not.toBeDisabled();
+ expect(screen.getByText("Fetch")).toBeInTheDocument();
+ });
+
+ it("should show Fetching state and keep table data visible during refetch", () => {
+ mockUseKeys.mockReturnValue({
+ data: {
+ keys: [mockKey],
+ total_count: 1,
+ current_page: 1,
+ total_pages: 1,
+ } as KeysResponse,
+ isPending: false,
+ isFetching: true,
+ refetch: vi.fn(),
+ } as any);
+
+ renderWithProviders();
+
+ // Button should show "Fetching" and be disabled
+ expect(screen.getByText("Fetching")).toBeInTheDocument();
+ const fetchButton = screen.getByTitle("Fetch data");
+ expect(fetchButton).toBeDisabled();
+
+ // Table data should still be visible (stale data)
+ expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
+
+ // "Loading keys..." should NOT appear during refetch
+ expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument();
+ });
+
+ it("should call refetch when Fetch button is clicked", () => {
+ const mockRefetch = vi.fn();
+ mockUseKeys.mockReturnValue({
+ data: {
+ keys: [mockKey],
+ total_count: 1,
+ current_page: 1,
+ total_pages: 1,
+ } as KeysResponse,
+ isPending: false,
+ isFetching: false,
+ refetch: mockRefetch,
+ } as any);
+
+ renderWithProviders();
+
+ const fetchButton = screen.getByTitle("Fetch data");
+ fireEvent.click(fetchButton);
+
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx
index fe9d58b9791..b4588a899d4 100644
--- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx
+++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx
@@ -24,9 +24,9 @@ import {
TableRow,
Text,
} from "@tremor/react";
-import { InfoCircleOutlined } from "@ant-design/icons";
-import { Popover, Skeleton, Tooltip } from "antd";
-import React, { useEffect, useMemo, useState } from "react";
+import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
+import { Button as AntButton, Popover, Skeleton, Tooltip } from "antd";
+import React, { useEffect, useDeferredValue, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { useFilterLogic } from "../key_team_helpers/filter_logic";
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
@@ -97,6 +97,15 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
organizations,
});
+ // Defer the transition so the button stays in loading state until the table
+ // has rendered with the new data (mirrors the spend-logs pattern)
+ const isFetchingDeferred = useDeferredValue(isFetching);
+ const isButtonLoading = isFetching || isFetchingDeferred;
+
+ const handleRefresh = () => {
+ refetch();
+ };
+
const totalCount = filteredTotalCount ?? keys?.total_count ?? 0;
// Add a useEffect to call refresh when a key is created
@@ -606,16 +615,28 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
- {isLoading || isFetching ? (
-
- ) : (
-
- Showing {rangeLabel} of {totalCount} results
-
- )}
+
+ {isLoading ? (
+
+ ) : (
+
+ Showing {rangeLabel} of {totalCount} results
+
+ )}
+
+
}
+ onClick={handleRefresh}
+ disabled={isButtonLoading}
+ title="Fetch data"
+ >
+ {isButtonLoading ? "Fetching" : "Fetch"}
+
+
- {isLoading || isFetching ? (
+ {isLoading ? (
) : (
@@ -623,24 +644,24 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
)}
- {isLoading || isFetching ? (
+ {isLoading ? (
) : (
)}
- {isLoading || isFetching ? (
+ {isLoading ? (
) : (