mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
refactor(ui): migrate models and endpoints table onto the shared DataTable (#34363)
* refactor(ui): migrate models and endpoints table onto the shared DataTable Rebuilds the All Models table on the shared DataTable, following the 2a treatment from the Models + Endpoints design: one card holding search, the Team and View selectors, refresh, columns and filters, with the active filters on a chip row and the pagination footer at the bottom. Retires the last hand-rolled tremor renderer (all_models_table.tsx) and the antd/tremor column defs in molecules/models/columns.tsx, replacing them with a thin AllModelsTable consumer plus AllModelsTableColumns built from the shared cell library. Behavior is preserved end to end. The server sort field mapping now lives next to the column ids so the two cannot drift. Status keeps its column and its sort, hidden by default behind the Columns menu because the design shows nine columns. Access groups collapse into a "+N more" tooltip instead of a per-row expand toggle, and the full reset moves into the filter drawer footer where the design puts it. Adds the shadcn hover-card primitive (Base UI PreviewCard in the base-vega style) for the model information hover, which needs an interactive surface a tooltip cannot provide. * fix(ui): stop the models tab re-querying on mount The mount-time effect fires the debounced search with the initial empty value, and its callback rebuilt the pagination object unconditionally. That produced a second render (and a second query) roughly 300ms after mount with no user input, which on a slow CI machine swapped the table's row nodes mid-interaction and made a click land on a detached node. resetToFirstPage now returns the existing state when already on the first page, so React bails out instead of re-rendering. Pinned with a test that asserts no additional query after the debounce settles; it fails without the fix.
This commit is contained in:
parent
bb388b2566
commit
fd494d2fb2
12 changed files with 1704 additions and 2796 deletions
|
|
@ -1143,25 +1143,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": {
|
||||
"max-params": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
|
|
@ -3483,17 +3464,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_dashboard/all_models_table.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_filters.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3560,28 +3530,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/molecules/models/columns.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/molecules/models/columns.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"max-params": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/molecules/notifications_manager.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -4179,6 +4127,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/hover-card.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/input-group.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,662 +1,364 @@
|
|||
import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AllModelsTab from "./AllModelsTab";
|
||||
|
||||
// Mock modelDeleteCall
|
||||
import AllModelsTab from "./AllModelsTab";
|
||||
import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns";
|
||||
|
||||
const mockModelDeleteCall = vi.fn().mockResolvedValue({});
|
||||
const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({});
|
||||
vi.mock("@/components/networking", () => ({
|
||||
modelDeleteCall: (...args: any[]) => mockModelDeleteCall(...args),
|
||||
modelDeleteCall: (...args: unknown[]) => mockModelDeleteCall(...args),
|
||||
modelPatchUpdateCall: (...args: unknown[]) => mockModelPatchUpdateCall(...args),
|
||||
}));
|
||||
|
||||
// Mock NotificationsManager
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal", () => ({
|
||||
default: function ModelSettingsModalMock({ isVisible }: { isVisible: boolean }) {
|
||||
return isVisible ? <div data-testid="model-settings-modal" /> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-query
|
||||
const mockInvalidateQueries = vi.fn();
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
};
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) };
|
||||
});
|
||||
|
||||
// Mock the useModelsInfo hook
|
||||
const mockUseModelsInfo = vi.fn(() => ({
|
||||
data: { data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})) as any;
|
||||
interface ModelsInfoArgs {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
teamId?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
const modelsInfoCalls: ModelsInfoArgs[] = [];
|
||||
const mockRefetch = vi.fn();
|
||||
let modelsInfoResult: Record<string, unknown> = {};
|
||||
|
||||
type UseModelsInfoArgs = [
|
||||
page?: number,
|
||||
size?: number,
|
||||
search?: string,
|
||||
modelId?: string,
|
||||
teamId?: string,
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
];
|
||||
|
||||
vi.mock("../../hooks/models/useModels", () => ({
|
||||
useModelsInfo: (page?: number, size?: number, search?: string) => mockUseModelsInfo(page, size, search),
|
||||
}));
|
||||
|
||||
// Mock the useModelCostMap hook
|
||||
const mockUseModelCostMap = vi.fn(() => ({
|
||||
data: {
|
||||
"gpt-4": { litellm_provider: "openai" },
|
||||
"gpt-3.5-turbo": { litellm_provider: "openai" },
|
||||
"gpt-4-accessible": { litellm_provider: "openai" },
|
||||
"gpt-3.5-turbo-blocked": { litellm_provider: "openai" },
|
||||
"gpt-4-sales": { litellm_provider: "openai" },
|
||||
"gpt-4-engineering": { litellm_provider: "openai" },
|
||||
"gpt-4-personal": { litellm_provider: "openai" },
|
||||
"gpt-4-team-only": { litellm_provider: "openai" },
|
||||
"gpt-4-config": { litellm_provider: "openai" },
|
||||
"gpt-4-db": { litellm_provider: "openai" },
|
||||
useModelsInfo: (...args: UseModelsInfoArgs) => {
|
||||
const [page, size, search, , teamId, sortBy, sortOrder] = args;
|
||||
const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder };
|
||||
modelsInfoCalls.push(call);
|
||||
return { ...modelsInfoResult, refetch: mockRefetch };
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})) as any;
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/models/useModelCostMap", () => ({
|
||||
useModelCostMap: () => mockUseModelCostMap(),
|
||||
useModelCostMap: () => ({ data: { "gpt-4": { litellm_provider: "openai" } }, isLoading: false, error: null }),
|
||||
}));
|
||||
|
||||
// Mock the useTeams hook (react-query implementation)
|
||||
const mockUseTeams = vi.fn(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})) as any;
|
||||
|
||||
const mockTeams = [{ team_id: "team-1", team_alias: "Engineering" }];
|
||||
vi.mock("../../hooks/teams/useTeams", () => ({
|
||||
useTeams: () => mockUseTeams(),
|
||||
useTeams: () => ({ data: mockTeams, isLoading: false, error: null, refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Helper function to create model cost map mock return value
|
||||
const createModelCostMapMock = (data: Record<string, any>) => ({
|
||||
data,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
const BASE_MODEL_INFO = {
|
||||
id: "model-1",
|
||||
db_model: true,
|
||||
created_by: "user-123",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
team_id: "team-1",
|
||||
access_groups: [],
|
||||
};
|
||||
|
||||
const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
model_name: "gpt-4",
|
||||
litellm_params: { model: "openai/gpt-4", custom_llm_provider: "openai" },
|
||||
model_info: { ...BASE_MODEL_INFO, ...((overrides.model_info as Record<string, unknown>) ?? {}) },
|
||||
});
|
||||
|
||||
// Helper function to create paginated model data mock
|
||||
const createPaginatedModelData = (
|
||||
models: any[],
|
||||
totalCount: number = models.length,
|
||||
currentPage: number = 1,
|
||||
totalPages: number = 1,
|
||||
size: number = 50,
|
||||
) => ({
|
||||
data: models,
|
||||
total_count: totalCount,
|
||||
current_page: currentPage,
|
||||
total_pages: totalPages,
|
||||
size: size,
|
||||
});
|
||||
const setModelsInfo = (rows: Record<string, unknown>[], totalCount = rows.length, isLoading = false) => {
|
||||
modelsInfoResult = {
|
||||
data: { data: rows, total_count: totalCount, current_page: 1, total_pages: 1, size: 50 },
|
||||
isLoading,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
|
||||
const lastModelsInfoCall = (): ModelsInfoArgs => modelsInfoCalls[modelsInfoCalls.length - 1];
|
||||
|
||||
const SEARCH_SETTLE_MS = 400;
|
||||
|
||||
const MOCK_AUTHORIZED = {
|
||||
isLoading: false,
|
||||
isAuthorized: true,
|
||||
token: "mock-token",
|
||||
accessToken: "mock-access-token",
|
||||
userId: "user-123",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
const mockSetSelectedModelGroup = vi.fn();
|
||||
const mockSetSelectedModelId = vi.fn();
|
||||
const mockSetSelectedTeamId = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedModelGroup: "all",
|
||||
setSelectedModelGroup: mockSetSelectedModelGroup,
|
||||
availableModelGroups: ["gpt-4", "gpt-3.5-turbo"],
|
||||
availableModelAccessGroups: ["sales-team"],
|
||||
setSelectedModelId: mockSetSelectedModelId,
|
||||
setSelectedTeamId: mockSetSelectedTeamId,
|
||||
};
|
||||
|
||||
describe("AllModelsTab", () => {
|
||||
const mockSetSelectedModelGroup = vi.fn();
|
||||
const mockSetSelectedModelId = vi.fn();
|
||||
const mockSetSelectedTeamId = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedModelGroup: "all",
|
||||
setSelectedModelGroup: mockSetSelectedModelGroup,
|
||||
availableModelGroups: ["gpt-4", "gpt-3.5-turbo"],
|
||||
availableModelAccessGroups: ["sales-team", "engineering-team"],
|
||||
setSelectedModelId: mockSetSelectedModelId,
|
||||
setSelectedTeamId: mockSetSelectedTeamId,
|
||||
};
|
||||
|
||||
const mockUseAuthorized = {
|
||||
token: "mock-token",
|
||||
accessToken: "mock-access-token",
|
||||
userId: "user-123",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(useAuthorizedModule, "default").mockReturnValue(mockUseAuthorized);
|
||||
modelsInfoCalls.length = 0;
|
||||
setModelsInfo([makeRow()]);
|
||||
vi.spyOn(useAuthorizedModule, "default").mockReturnValue(MOCK_AUTHORIZED);
|
||||
});
|
||||
|
||||
it("should render with empty data", () => {
|
||||
mockUseModelsInfo.mockReturnValueOnce({
|
||||
data: createPaginatedModelData([], 0, 1, 1, 50),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
it("renders the fetched models and the server row count", async () => {
|
||||
setModelsInfo([makeRow()], 137);
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseTeams.mockReturnValueOnce({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseModelCostMap.mockReturnValueOnce(createModelCostMapMock({}));
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
expect(screen.getByText("Current Team:")).toBeInTheDocument();
|
||||
expect(await screen.findByText("gpt-4")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137");
|
||||
});
|
||||
|
||||
it("should filter models by direct team access when current team is selected", async () => {
|
||||
const mockTeams = [
|
||||
{
|
||||
team_id: "team-456",
|
||||
team_alias: "Engineering Team",
|
||||
models: ["gpt-4"],
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "org-123",
|
||||
created_at: "2024-01-01",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
},
|
||||
it("does not re-query after the mount-time debounced search settles unchanged", async () => {
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
const callsAfterMount = modelsInfoCalls.length;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_SETTLE_MS));
|
||||
|
||||
expect(modelsInfoCalls.length).toBe(callsAfterMount);
|
||||
});
|
||||
|
||||
it("shows the empty state when the proxy returns no models", () => {
|
||||
setModelsInfo([], 0);
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("No models found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loading skeleton while the first page is in flight", () => {
|
||||
setModelsInfo([], 0, true);
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("No models found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("server sort contract", () => {
|
||||
const sortHeader = (columnId: string): HTMLElement => screen.getByTestId(`sort-header-${columnId}`);
|
||||
|
||||
const expectIndicator = async (columnId: string, state: "asc" | "desc" | "none") => {
|
||||
await waitFor(() => {
|
||||
expect(sortHeader(columnId).querySelector(`[data-sort-indicator="${state}"]`)).not.toBeNull();
|
||||
});
|
||||
};
|
||||
|
||||
const cases: [string, string, string, "asc" | "desc"][] = [
|
||||
["Model Information", "model_name", "model_name", "asc"],
|
||||
["Created By", "model_info_created_by", "created_at", "asc"],
|
||||
["Updated At", "model_info_updated_at", "updated_at", "asc"],
|
||||
["Costs", "input_cost", "costs", "desc"],
|
||||
];
|
||||
|
||||
mockUseTeams.mockReturnValueOnce({
|
||||
data: mockTeams,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
it.each(cases)("sorts %s using the server field %s", async (_label, columnId, serverField, firstDirection) => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
await user.click(sortHeader(columnId));
|
||||
await expectIndicator(columnId, firstDirection);
|
||||
|
||||
expect(lastModelsInfoCall().sortBy).toBe(serverField);
|
||||
expect(lastModelsInfoCall().sortOrder).toBe(firstDirection);
|
||||
});
|
||||
|
||||
mockUseModelCostMap.mockReturnValueOnce(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-accessible": { litellm_provider: "openai" },
|
||||
"gpt-3.5-turbo-blocked": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
it("maps the hidden Status column to the server field status", () => {
|
||||
expect(toServerSortField(STATUS_COLUMN_ID)).toBe("status");
|
||||
});
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-accessible",
|
||||
model_info: {
|
||||
id: "model-1",
|
||||
access_via_team_ids: ["team-456"],
|
||||
access_groups: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
model_name: "gpt-3.5-turbo-blocked",
|
||||
model_info: {
|
||||
id: "model-2",
|
||||
access_via_team_ids: ["team-789"],
|
||||
access_groups: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
it("cycles a sorted column back to unsorted", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
|
||||
await user.click(sortHeader("model_info_updated_at"));
|
||||
await expectIndicator("model_info_updated_at", "asc");
|
||||
expect(lastModelsInfoCall().sortOrder).toBe("asc");
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
await user.click(sortHeader("model_info_updated_at"));
|
||||
await expectIndicator("model_info_updated_at", "desc");
|
||||
expect(lastModelsInfoCall().sortOrder).toBe("desc");
|
||||
|
||||
// Component shows API total_count (2), not filtered count
|
||||
// Since default is "personal" team and models don't have direct_access, they're filtered out
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument();
|
||||
await user.click(sortHeader("model_info_updated_at"));
|
||||
await expectIndicator("model_info_updated_at", "none");
|
||||
expect(lastModelsInfoCall().sortBy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter models by access group matching when team models match model access groups", async () => {
|
||||
const mockTeams = [
|
||||
{
|
||||
team_id: "team-sales",
|
||||
team_alias: "Sales Team",
|
||||
models: ["sales-model-group"],
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "org-123",
|
||||
created_at: "2024-01-01",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
},
|
||||
];
|
||||
it("queries the selected team and resets to the first page", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: mockTeams,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
expect(lastModelsInfoCall().teamId).toBeUndefined();
|
||||
|
||||
mockUseModelCostMap.mockReturnValueOnce(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-sales": { litellm_provider: "openai" },
|
||||
"gpt-4-engineering": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByTestId("models-team-select"));
|
||||
await user.click(await screen.findByRole("option", { name: "Engineering" }));
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-sales",
|
||||
model_info: {
|
||||
id: "model-sales-1",
|
||||
access_via_team_ids: [],
|
||||
access_groups: ["sales-model-group"],
|
||||
},
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4-engineering",
|
||||
model_info: {
|
||||
id: "model-eng-1",
|
||||
access_via_team_ids: [],
|
||||
access_groups: ["engineering-model-group"],
|
||||
},
|
||||
},
|
||||
],
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
// Component shows API total_count (2), not filtered count
|
||||
// Since default is "personal" team and models don't have direct_access, they're filtered out
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument();
|
||||
expect(lastModelsInfoCall().teamId).toBe("team-1");
|
||||
});
|
||||
expect(lastModelsInfoCall().page).toBe(1);
|
||||
});
|
||||
|
||||
it("debounces the model name search into the server query", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "claude");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastModelsInfoCall().search).toBe("claude");
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter models by direct_access for personal team", async () => {
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("applies a public model name filter through the drawer", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseModelCostMap.mockReturnValueOnce(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-personal": { litellm_provider: "openai" },
|
||||
"gpt-4-team-only": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await user.click(await screen.findByPlaceholderText("Filter by Public Model Name"));
|
||||
await user.click(await screen.findByRole("option", { name: "gpt-3.5-turbo" }));
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-personal",
|
||||
model_info: {
|
||||
id: "model-personal-1",
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4-team-only",
|
||||
model_info: {
|
||||
id: "model-team-1",
|
||||
direct_access: false,
|
||||
access_via_team_ids: ["team-123"],
|
||||
access_groups: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
// Component shows API total_count (2), but only 1 model has direct_access
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument();
|
||||
expect(mockSetSelectedModelGroup).toHaveBeenCalledWith("gpt-3.5-turbo");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show config model status for models defined in configs", async () => {
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("filters the fetched page down to the selected model group", () => {
|
||||
setModelsInfo([makeRow(), { ...makeRow(), model_name: "claude-opus" }], 2);
|
||||
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
|
||||
|
||||
mockUseModelCostMap.mockReturnValueOnce(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-config": { litellm_provider: "openai" },
|
||||
"gpt-4-db": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
const table = screen.getByRole("table");
|
||||
expect(within(table).getByText("claude-opus")).toBeInTheDocument();
|
||||
expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-config",
|
||||
litellm_model_name: "gpt-4-config",
|
||||
provider: "openai",
|
||||
model_info: {
|
||||
id: "model-config-1",
|
||||
db_model: false,
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
created_by: "user-123",
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4-db",
|
||||
litellm_model_name: "gpt-4-db",
|
||||
provider: "openai",
|
||||
model_info: {
|
||||
id: "model-db-1",
|
||||
db_model: true,
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
created_by: "user-123",
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
},
|
||||
},
|
||||
],
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
it("resets search, filters, team and sorting from the drawer reset button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />);
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
|
||||
await user.click(screen.getByTestId("models-team-select"));
|
||||
await user.click(await screen.findByRole("option", { name: "Engineering" }));
|
||||
await waitFor(() => expect(lastModelsInfoCall().teamId).toBe("team-1"));
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await user.click(await screen.findByTestId("filter-drawer-reset"));
|
||||
|
||||
expect(mockSetSelectedModelGroup).toHaveBeenCalledWith("all");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Config Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("DB Model")).toBeInTheDocument();
|
||||
expect(lastModelsInfoCall().teamId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show 'Defined in config' for models defined in configs", async () => {
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("opens the delete modal from the row and deletes the model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseModelCostMap.mockReturnValueOnce(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-config": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByTestId("model-delete-model-1"));
|
||||
expect(await screen.findByText("Delete Model")).toBeInTheDocument();
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-config",
|
||||
litellm_model_name: "gpt-4-config",
|
||||
provider: "openai",
|
||||
model_info: {
|
||||
id: "model-config-1",
|
||||
db_model: false,
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
created_by: "user-123",
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null });
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
await user.click(screen.getByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Defined in config")).toBeInTheDocument();
|
||||
expect(mockModelDeleteCall).toHaveBeenCalledWith("mock-access-token", "model-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle pagination: Previous button is disabled on first page and Next button works", async () => {
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("pauses a model through the row toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseModelCostMap.mockReturnValue(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-page1": { litellm_provider: "openai" },
|
||||
"gpt-4-page2": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock first page response (page 1 of 2)
|
||||
const page1Data = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-page1",
|
||||
model_info: {
|
||||
id: "model-page1-1",
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
2, // total_count
|
||||
1, // current_page
|
||||
2, // total_pages
|
||||
50, // size
|
||||
);
|
||||
|
||||
// Set up mock to return page1Data for page 1
|
||||
mockUseModelsInfo.mockImplementation((page: number = 1, size?: number, search?: string) => {
|
||||
return { data: page1Data, isLoading: false, error: null };
|
||||
});
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
await user.click(await screen.findByTestId("model-pause-toggle-model-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Component calculates: ((1-1)*50)+1 = 1, Math.min(1*50, 2) = 2
|
||||
expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument();
|
||||
expect(mockModelPatchUpdateCall).toHaveBeenCalledWith("mock-access-token", { blocked: true }, "model-1");
|
||||
});
|
||||
|
||||
// Check that Previous button is disabled on first page
|
||||
const previousButton = screen.getByRole("button", { name: /previous/i });
|
||||
expect(previousButton).toBeDisabled();
|
||||
|
||||
// Check that Next button is enabled (since we're on page 1 of 2)
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should handle pagination: Next button is disabled on last page", async () => {
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("opens the model settings modal from the toolbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseModelCostMap.mockReturnValue(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-page2": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock single page response (page 1 of 1 - last page)
|
||||
const singlePageData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-page2",
|
||||
model_info: {
|
||||
id: "model-page2-1",
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
1, // total_count
|
||||
1, // current_page
|
||||
1, // total_pages (only 1 page, so this is the last page)
|
||||
50, // size
|
||||
);
|
||||
|
||||
mockUseModelsInfo.mockImplementation((page?: number, size?: number, search?: string) => {
|
||||
return { data: singlePageData, isLoading: false, error: null };
|
||||
});
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// When there's only 1 page (last page), Next should be disabled
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
|
||||
// Previous should also be disabled on the first (and only) page
|
||||
const previousButton = screen.getByRole("button", { name: /previous/i });
|
||||
expect(previousButton).toBeDisabled();
|
||||
expect(screen.queryByTestId("model-settings-modal")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("models-settings-trigger"));
|
||||
expect(screen.getByTestId("model-settings-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass setDeleteModalModelId to columns for delete functionality", async () => {
|
||||
// This test verifies that the delete modal setter is passed to columns
|
||||
// The actual modal rendering is handled by DeleteResourceModal component
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("opens the model detail view from the model ID cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
mockUseModelCostMap.mockReturnValue(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-delete-test": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByTestId("model-id-model-1"));
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-delete-test",
|
||||
litellm_model_name: "gpt-4-delete-test",
|
||||
provider: "openai",
|
||||
model_info: {
|
||||
id: "model-to-delete",
|
||||
db_model: true,
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
created_by: "user-123",
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() });
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4-delete-test")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify the DB Model badge is shown (indicating it can be deleted)
|
||||
expect(screen.getByText("DB Model")).toBeInTheDocument();
|
||||
expect(mockSetSelectedModelId).toHaveBeenCalledWith("model-1");
|
||||
});
|
||||
|
||||
it("should render clickable model ID that calls setSelectedModelId", async () => {
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
it("opens the team detail view from the team ID cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
await user.click(await screen.findByTestId("model-team-id-model-1"));
|
||||
|
||||
expect(mockSetSelectedTeamId).toHaveBeenCalledWith("team-1");
|
||||
});
|
||||
|
||||
describe("virtual key hint", () => {
|
||||
it("explains personal key creation while viewing current team models", () => {
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/create a Virtual Key without selecting a team/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockUseModelCostMap.mockReturnValue(
|
||||
createModelCostMapMock({
|
||||
"gpt-4-clickable": { litellm_provider: "openai" },
|
||||
}),
|
||||
);
|
||||
it("names the selected team in the hint", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
const modelData = createPaginatedModelData(
|
||||
[
|
||||
{
|
||||
model_name: "gpt-4-clickable",
|
||||
litellm_model_name: "gpt-4-clickable",
|
||||
provider: "openai",
|
||||
model_info: {
|
||||
id: "clickable-model-id",
|
||||
db_model: true,
|
||||
direct_access: true,
|
||||
access_via_team_ids: [],
|
||||
access_groups: [],
|
||||
created_by: "user-123",
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
await user.click(screen.getByTestId("models-team-select"));
|
||||
await user.click(await screen.findByRole("option", { name: "Engineering" }));
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() });
|
||||
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4-clickable")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/select Team as "Engineering"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the Model ID cell which should call setSelectedModelId
|
||||
const modelIdCell = screen.getByText("clickable-model-id");
|
||||
expect(modelIdCell).toBeInTheDocument();
|
||||
it("hides the hint when viewing all available models", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
fireEvent.click(modelIdCell);
|
||||
await user.click(screen.getByTestId("models-view-select"));
|
||||
await user.click(await screen.findByRole("option", { name: "All Available Models" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSelectedModelId).toHaveBeenCalledWith("clickable-model-id");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/create a Virtual Key/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { AllModelsDataTable } from "@/components/model_dashboard/all_models_table";
|
||||
import { columns } from "@/components/molecules/models/columns";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
|
||||
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Grid } from "@tremor/react";
|
||||
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Info } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useModelsInfo } from "../../hooks/models/useModels";
|
||||
import { transformModelData } from "../utils/modelDataTransformer";
|
||||
type ModelViewMode = "all" | "current_team";
|
||||
import {
|
||||
ALL_MODEL_GROUPS_VALUE,
|
||||
AllModelsTable,
|
||||
ModelViewMode,
|
||||
PERSONAL_TEAM_VALUE,
|
||||
WILDCARD_MODEL_GROUP_VALUE,
|
||||
} from "./AllModelsTable";
|
||||
import { ACCESS_GROUPS_COLUMN_ID, MODEL_NAME_COLUMN_ID, toServerSortField } from "./ModelsTableColumns";
|
||||
|
||||
const SEARCH_DEBOUNCE_WAIT_MS = 200;
|
||||
const { Text } = Typography;
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE };
|
||||
|
||||
interface AllModelsTabProps {
|
||||
selectedModelGroup: string | null;
|
||||
|
|
@ -41,31 +47,30 @@ const AllModelsTab = ({
|
|||
setSelectedTeamId,
|
||||
}: AllModelsTabProps) => {
|
||||
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
|
||||
const { accessToken, userId, userRole, premiumUser } = useAuthorized();
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [modelNameSearch, setModelNameSearch] = useState<string>("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||
const [modelViewMode, setModelViewMode] = useState<ModelViewMode>("current_team");
|
||||
const [currentTeam, setCurrentTeam] = useState<Team | "personal">("personal");
|
||||
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||
const [selectedTeamValue, setSelectedTeamValue] = useState<string>(PERSONAL_TEAM_VALUE);
|
||||
const [selectedModelAccessGroupFilter, setSelectedModelAccessGroupFilter] = useState<string | null>(null);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize] = useState<number>(50);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>(DEFAULT_PAGINATION);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false);
|
||||
const [deleteModalModelId, setDeleteModalModelId] = useState<string | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [pausingModelId, setPausingModelId] = useState<string | null>(null);
|
||||
|
||||
const resetToFirstPage = useCallback(() => {
|
||||
setPagination((previous) => (previous.pageIndex === 0 ? previous : { ...previous, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const debouncedUpdateSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
setDebouncedSearch(value);
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
resetToFirstPage();
|
||||
},
|
||||
{ wait: SEARCH_DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
|
@ -74,125 +79,130 @@ const AllModelsTab = ({
|
|||
debouncedUpdateSearch(modelNameSearch);
|
||||
}, [modelNameSearch, debouncedUpdateSearch]);
|
||||
|
||||
// Determine teamId to pass to the query - only pass if not "personal"
|
||||
const teamIdForQuery = currentTeam === "personal" ? undefined : currentTeam.team_id;
|
||||
const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue;
|
||||
|
||||
// Convert sorting state to sortBy and sortOrder for API
|
||||
const sortBy = useMemo(() => {
|
||||
if (sorting.length === 0) return undefined;
|
||||
const sort = sorting[0];
|
||||
const columnIdToServerField: Record<string, string> = {
|
||||
input_cost: "costs", // Map input_cost column to "costs" for server-side sorting
|
||||
model_info_db_model: "status", // Map model_info.db_model column to "status" for server-side sorting
|
||||
model_info_created_by: "created_at", // Map model_info.created_by column to "created_at" for server-side sorting
|
||||
model_info_updated_at: "updated_at", // Map model_info.updated_at column to "updated_at" for server-side sorting
|
||||
};
|
||||
return columnIdToServerField[sort.id] || sort.id;
|
||||
return toServerSortField(sorting[0].id);
|
||||
}, [sorting]);
|
||||
|
||||
const sortOrder = useMemo(() => {
|
||||
if (sorting.length === 0) return undefined;
|
||||
const sort = sorting[0];
|
||||
return sort.desc ? "desc" : "asc";
|
||||
return sorting[0].desc ? "desc" : "asc";
|
||||
}, [sorting]);
|
||||
|
||||
const {
|
||||
data: rawModelData,
|
||||
isLoading: isLoadingModelsInfo,
|
||||
isFetching: isFetchingModelsInfo,
|
||||
refetch: refetchModels,
|
||||
} = useModelsInfo(currentPage, pageSize, debouncedSearch || undefined, undefined, teamIdForQuery, sortBy, sortOrder);
|
||||
} = useModelsInfo(
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
debouncedSearch || undefined,
|
||||
undefined,
|
||||
teamIdForQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
);
|
||||
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
|
||||
|
||||
const getProviderFromModel = (model: string) => {
|
||||
if (modelCostMapData !== null && modelCostMapData !== undefined) {
|
||||
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
|
||||
return modelCostMapData[model]["litellm_provider"];
|
||||
const getProviderFromModel = useCallback(
|
||||
(model: string) => {
|
||||
if (modelCostMapData !== null && modelCostMapData !== undefined) {
|
||||
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
|
||||
return modelCostMapData[model]["litellm_provider"];
|
||||
}
|
||||
}
|
||||
}
|
||||
return "openai";
|
||||
};
|
||||
return "openai";
|
||||
},
|
||||
[modelCostMapData],
|
||||
);
|
||||
|
||||
const modelData = useMemo(() => {
|
||||
if (!rawModelData) return { data: [] };
|
||||
return transformModelData(rawModelData, getProviderFromModel);
|
||||
}, [rawModelData, modelCostMapData]);
|
||||
}, [rawModelData, getProviderFromModel]);
|
||||
|
||||
const [deleteModalModelId, setDeleteModalModelId] = useState<string | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// Get pagination metadata from the response
|
||||
const paginationMeta = useMemo(() => {
|
||||
if (!rawModelData) {
|
||||
return {
|
||||
total_count: 0,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: pageSize,
|
||||
};
|
||||
}
|
||||
return {
|
||||
total_count: rawModelData.total_count ?? 0,
|
||||
current_page: rawModelData.current_page ?? 1,
|
||||
total_pages: rawModelData.total_pages ?? 1,
|
||||
size: rawModelData.size ?? pageSize,
|
||||
};
|
||||
}, [rawModelData, pageSize]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const filteredData = useMemo<ModelData[]>(() => {
|
||||
if (!modelData || !modelData.data || modelData.data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Server-side search is now handled by the API, so we only filter by other criteria
|
||||
return modelData.data.filter((model: any) => {
|
||||
return modelData.data.filter((model: ModelData) => {
|
||||
const modelNameMatch =
|
||||
selectedModelGroup === "all" ||
|
||||
selectedModelGroup === ALL_MODEL_GROUPS_VALUE ||
|
||||
model.model_name === selectedModelGroup ||
|
||||
!selectedModelGroup ||
|
||||
(selectedModelGroup === "wildcard" && model.model_name?.includes("*"));
|
||||
(selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE && model.model_name?.includes("*"));
|
||||
|
||||
const accessGroupMatch =
|
||||
selectedModelAccessGroupFilter === "all" ||
|
||||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) ||
|
||||
selectedModelAccessGroupFilter === ALL_MODEL_GROUPS_VALUE ||
|
||||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter ?? "") ||
|
||||
!selectedModelAccessGroupFilter;
|
||||
|
||||
// Team filtering is now handled server-side via teamId query parameter
|
||||
// Only apply client-side filtering for model groups and access groups
|
||||
return modelNameMatch && accessGroupMatch;
|
||||
});
|
||||
}, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
setCurrentPage(1);
|
||||
}, [selectedModelGroup, selectedModelAccessGroupFilter]);
|
||||
const columnFilters = useMemo<ColumnFiltersState>(
|
||||
() =>
|
||||
[
|
||||
selectedModelGroup && selectedModelGroup !== ALL_MODEL_GROUPS_VALUE
|
||||
? { id: MODEL_NAME_COLUMN_ID, value: selectedModelGroup }
|
||||
: null,
|
||||
selectedModelAccessGroupFilter ? { id: ACCESS_GROUPS_COLUMN_ID, value: selectedModelAccessGroupFilter } : null,
|
||||
].filter((entry) => entry !== null),
|
||||
[selectedModelGroup, selectedModelAccessGroupFilter],
|
||||
);
|
||||
|
||||
// Reset pagination when team changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}, [teamIdForQuery]);
|
||||
const handleColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(columnFilters) : updater;
|
||||
const modelGroup = next.find((entry) => entry.id === MODEL_NAME_COLUMN_ID)?.value;
|
||||
const accessGroup = next.find((entry) => entry.id === ACCESS_GROUPS_COLUMN_ID)?.value;
|
||||
setSelectedModelGroup(typeof modelGroup === "string" ? modelGroup : ALL_MODEL_GROUPS_VALUE);
|
||||
setSelectedModelAccessGroupFilter(typeof accessGroup === "string" ? accessGroup : null);
|
||||
resetToFirstPage();
|
||||
};
|
||||
|
||||
// Reset pagination when sorting changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}, [sorting]);
|
||||
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||
setSorting(typeof updater === "function" ? updater(sorting) : updater);
|
||||
resetToFirstPage();
|
||||
};
|
||||
|
||||
const handleTeamChange = (value: string) => {
|
||||
setSelectedTeamValue(value);
|
||||
resetToFirstPage();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setModelNameSearch("");
|
||||
setSelectedModelGroup("all");
|
||||
setSelectedModelGroup(ALL_MODEL_GROUPS_VALUE);
|
||||
setSelectedModelAccessGroupFilter(null);
|
||||
setCurrentTeam("personal");
|
||||
setSelectedTeamValue(PERSONAL_TEAM_VALUE);
|
||||
setModelViewMode("current_team");
|
||||
setCurrentPage(1);
|
||||
setPagination({ pageIndex: 0, pageSize: 50 });
|
||||
setPagination(DEFAULT_PAGINATION);
|
||||
setSorting([]);
|
||||
};
|
||||
|
||||
const teamOptions = useMemo(
|
||||
() => [
|
||||
{ value: PERSONAL_TEAM_VALUE, label: "Personal" },
|
||||
...(teams ?? [])
|
||||
.filter((team) => team.team_id)
|
||||
.map((team) => ({ value: team.team_id, label: team.team_alias ? team.team_alias : team.team_id })),
|
||||
],
|
||||
[teams],
|
||||
);
|
||||
|
||||
const selectedTeam = useMemo(
|
||||
() => (teams ?? []).find((team) => team.team_id === selectedTeamValue) ?? null,
|
||||
[teams, selectedTeamValue],
|
||||
);
|
||||
|
||||
const modelToDelete = useMemo(() => {
|
||||
if (!deleteModalModelId || !modelData?.data) return null;
|
||||
return modelData.data.find((model: any) => model.model_info.id === deleteModalModelId);
|
||||
return modelData.data.find((model: ModelData) => model.model_info.id === deleteModalModelId);
|
||||
}, [deleteModalModelId, modelData]);
|
||||
|
||||
const handleDeleteModel = async () => {
|
||||
|
|
@ -212,356 +222,99 @@ const AllModelsTab = ({
|
|||
}
|
||||
};
|
||||
|
||||
const [pausingModelId, setPausingModelId] = useState<string | null>(null);
|
||||
const handleTogglePause = useCallback(
|
||||
async (modelId: string, blocked: boolean) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setPausingModelId(modelId);
|
||||
await modelPatchUpdateCall(accessToken, { blocked }, modelId);
|
||||
NotificationsManager.success(blocked ? "Model paused" : "Model resumed");
|
||||
// invalidateQueries already schedules a refetch for active observers
|
||||
// on this key — no need to also call refetchModels() (would double-fetch).
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
} catch (error) {
|
||||
console.error("Error toggling model pause state:", error);
|
||||
NotificationsManager.fromBackend(error);
|
||||
} finally {
|
||||
setPausingModelId(null);
|
||||
}
|
||||
},
|
||||
[accessToken, queryClient],
|
||||
);
|
||||
|
||||
const handleTogglePause = async (modelId: string, blocked: boolean) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setPausingModelId(modelId);
|
||||
await modelPatchUpdateCall(accessToken, { blocked }, modelId);
|
||||
NotificationsManager.success(blocked ? "Model paused" : "Model resumed");
|
||||
// invalidateQueries already schedules a refetch for active observers
|
||||
// on this key — no need to also call refetchModels() (would double-fetch).
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
} catch (error) {
|
||||
console.error("Error toggling model pause state:", error);
|
||||
NotificationsManager.fromBackend(error);
|
||||
} finally {
|
||||
setPausingModelId(null);
|
||||
}
|
||||
};
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchModels();
|
||||
}, [refetchModels]);
|
||||
|
||||
const handleDeleteClick = useCallback((modelId: string) => {
|
||||
setDeleteModalModelId(modelId);
|
||||
}, []);
|
||||
|
||||
const handleOpenModelSettings = useCallback(() => {
|
||||
setIsModelSettingsModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const teamAccessLabel = selectedTeam?.team_alias || selectedTeam?.team_id || "";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Grid>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
{/* Current Team and View Mode Selector - Prominent Section */}
|
||||
<div className="border-b px-6 py-4 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Text className="text-lg font-semibold text-gray-900">Current Team:</Text>
|
||||
<div className="w-80">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active block size="large" />
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
defaultValue="personal"
|
||||
value={currentTeam === "personal" ? "personal" : currentTeam.team_id}
|
||||
onChange={(value) => {
|
||||
if (value === "personal") {
|
||||
setCurrentTeam("personal");
|
||||
// Reset to page 1 when team changes
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
} else {
|
||||
const team = teams?.find((t) => t.team_id === value);
|
||||
if (team) {
|
||||
setCurrentTeam(team);
|
||||
// Reset to page 1 when team changes
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading={isLoadingTeams}
|
||||
options={[
|
||||
{
|
||||
value: "personal",
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="blue" size="small" />
|
||||
<Text style={{ fontSize: 16 }}>Personal</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
...(teams
|
||||
?.filter((team) => team.team_id)
|
||||
.map((team) => ({
|
||||
value: team.team_id,
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="green" size="small" />
|
||||
<Text ellipsis style={{ fontSize: 16 }}>
|
||||
{team.team_alias ? team.team_alias : team.team_id}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Text className="text-lg font-semibold text-gray-900">View:</Text>
|
||||
<div className="w-64">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active block size="large" />
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
defaultValue="current_team"
|
||||
value={modelViewMode}
|
||||
onChange={(value) => setModelViewMode(value as "current_team" | "all")}
|
||||
options={[
|
||||
{
|
||||
value: "current_team",
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="purple" size="small" />
|
||||
<Text style={{ fontSize: 16 }}>Current Team Models</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Badge color="gray" size="small" />
|
||||
<Text style={{ fontSize: 16 }}>All Available Models</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<AllModelsTable
|
||||
data={filteredData}
|
||||
rowCount={rawModelData?.total_count ?? 0}
|
||||
isLoading={isLoading}
|
||||
isRefreshing={isFetchingModelsInfo}
|
||||
onRefresh={handleRefresh}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={handleColumnFiltersChange}
|
||||
onResetFilters={resetFilters}
|
||||
searchValue={modelNameSearch}
|
||||
onSearchChange={setModelNameSearch}
|
||||
teamOptions={teamOptions}
|
||||
selectedTeamValue={selectedTeamValue}
|
||||
onTeamChange={handleTeamChange}
|
||||
isLoadingTeams={isLoadingTeams}
|
||||
viewMode={modelViewMode}
|
||||
onViewModeChange={setModelViewMode}
|
||||
onOpenModelSettings={handleOpenModelSettings}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
userRole={userRole}
|
||||
userID={userId}
|
||||
onModelIdClick={setSelectedModelId}
|
||||
onTeamIdClick={setSelectedTeamId}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onTogglePauseClick={handleTogglePause}
|
||||
pausingModelId={pausingModelId}
|
||||
/>
|
||||
|
||||
{modelViewMode === "current_team" && (
|
||||
<div className="flex items-start gap-2 mt-3">
|
||||
<InfoCircleOutlined className="text-gray-400 mt-0.5 shrink-0 text-xs" />
|
||||
<div className="text-xs text-gray-500">
|
||||
{currentTeam === "personal" ? (
|
||||
<span>
|
||||
To access these models: Create a Virtual Key without selecting a team on the{" "}
|
||||
<a
|
||||
href="/public?login=success&page=api-keys"
|
||||
className="text-gray-600 hover:text-gray-800 underline"
|
||||
>
|
||||
Virtual Keys page
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
To access these models: Create a Virtual Key and select Team as "
|
||||
{typeof currentTeam !== "string" ? currentTeam.team_alias || currentTeam.team_id : ""}" on
|
||||
the{" "}
|
||||
<a
|
||||
href="/public?login=success&page=api-keys"
|
||||
className="text-gray-600 hover:text-gray-800 underline"
|
||||
>
|
||||
Virtual Keys page
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Model Name Search */}
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search model names..."
|
||||
data-testid="model-search-input"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={modelNameSearch}
|
||||
onChange={(e) => setModelNameSearch(e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
Filters
|
||||
</button>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
<button
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Settings Button */}
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setIsModelSettingsModalVisible(true)}
|
||||
title="Model Settings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
{/* Model Name Filter */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedModelGroup ?? "all"}
|
||||
onChange={(value) => setSelectedModelGroup(value === "all" ? "all" : value)}
|
||||
placeholder="Filter by Public Model Name"
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "all", label: "All Models" },
|
||||
{ value: "wildcard", label: "Wildcard Models (*)" },
|
||||
...availableModelGroups.map((group, idx) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Access Group Filter */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedModelAccessGroupFilter ?? "all"}
|
||||
onChange={(value) => setSelectedModelAccessGroupFilter(value === "all" ? null : value)}
|
||||
placeholder="Filter by Model Access Group"
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "all", label: "All Model Access Groups" },
|
||||
...availableModelAccessGroups.map((accessGroup, idx) => ({
|
||||
value: accessGroup,
|
||||
label: accessGroup,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Count and Pagination Controls */}
|
||||
<div className="flex justify-between items-center">
|
||||
{isLoading ? (
|
||||
<Skeleton.Input active style={{ width: 184, height: 20 }} />
|
||||
) : (
|
||||
<span data-testid="models-results-count" className="text-sm text-gray-700">
|
||||
{paginationMeta.total_count > 0
|
||||
? `Showing ${(currentPage - 1) * pageSize + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results`
|
||||
: "Showing 0 results"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isLoading ? (
|
||||
<Skeleton.Button active style={{ width: 84, height: 30 }} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newPage = currentPage - 1;
|
||||
setCurrentPage(newPage);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton.Button active style={{ width: 56, height: 30 }} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newPage = currentPage + 1;
|
||||
setCurrentPage(newPage);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
disabled={currentPage >= paginationMeta.total_pages}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
currentPage >= paginationMeta.total_pages
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AllModelsDataTable
|
||||
columns={columns(
|
||||
userRole,
|
||||
userId,
|
||||
premiumUser,
|
||||
setSelectedModelId,
|
||||
setSelectedTeamId,
|
||||
getDisplayModelName,
|
||||
() => {},
|
||||
() => {},
|
||||
expandedRows,
|
||||
setExpandedRows,
|
||||
setDeleteModalModelId,
|
||||
handleTogglePause,
|
||||
pausingModelId,
|
||||
)}
|
||||
data={filteredData}
|
||||
isLoading={isLoadingModelsInfo}
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
enablePagination={true}
|
||||
onRowClick={(model: any) => setSelectedModelId(model.model_info.id)}
|
||||
/>
|
||||
{modelViewMode === "current_team" && (
|
||||
<div className="flex items-start gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0" />
|
||||
{selectedTeamValue === PERSONAL_TEAM_VALUE ? (
|
||||
<span>
|
||||
To access these models, create a Virtual Key without selecting a team on the{" "}
|
||||
<a href="/public?login=success&page=api-keys" className="font-medium text-blue-600 hover:underline">
|
||||
Virtual Keys page
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
To access these models, create a Virtual Key and select Team as "{teamAccessLabel}" on the{" "}
|
||||
<a href="/public?login=success&page=api-keys" className="font-medium text-blue-600 hover:underline">
|
||||
Virtual Keys page
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={!!deleteModalModelId}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
|
||||
import { AllModelsTable } from "./AllModelsTable";
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
const makeModel = (overrides: Partial<ModelData> = {}): ModelData =>
|
||||
({
|
||||
model_name: "gpt-4-public",
|
||||
litellm_model_name: "openai/gpt-4",
|
||||
provider: "openai",
|
||||
input_cost: 30 as unknown as number,
|
||||
output_cost: 60 as unknown as number,
|
||||
max_tokens: 8192,
|
||||
max_input_tokens: 8192,
|
||||
litellm_params: { model: "openai/gpt-4" },
|
||||
cleanedLitellmParams: {},
|
||||
...overrides,
|
||||
model_info: {
|
||||
id: "model-1",
|
||||
created_at: "2024-01-02T00:00:00Z",
|
||||
updated_at: "2024-03-04T00:00:00Z",
|
||||
created_by: "alice",
|
||||
team_id: "team-1",
|
||||
db_model: true,
|
||||
access_groups: null,
|
||||
...(overrides.model_info ?? {}),
|
||||
},
|
||||
}) as ModelData;
|
||||
|
||||
const baseProps = {
|
||||
data: [makeModel()],
|
||||
rowCount: 1,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
onRefresh: vi.fn(),
|
||||
sorting: [],
|
||||
onSortingChange: vi.fn(),
|
||||
pagination: { pageIndex: 0, pageSize: 50 },
|
||||
onPaginationChange: vi.fn(),
|
||||
columnFilters: [],
|
||||
onColumnFiltersChange: vi.fn(),
|
||||
onResetFilters: vi.fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: vi.fn(),
|
||||
teamOptions: [
|
||||
{ value: "personal", label: "Personal" },
|
||||
{ value: "team-1", label: "Engineering" },
|
||||
],
|
||||
selectedTeamValue: "personal",
|
||||
onTeamChange: vi.fn(),
|
||||
isLoadingTeams: false,
|
||||
viewMode: "current_team" as const,
|
||||
onViewModeChange: vi.fn(),
|
||||
onOpenModelSettings: vi.fn(),
|
||||
availableModelGroups: ["gpt-4", "gpt-3.5-turbo"],
|
||||
availableModelAccessGroups: ["sales-team"],
|
||||
userRole: "Admin",
|
||||
userID: "alice",
|
||||
onModelIdClick: vi.fn(),
|
||||
onTeamIdClick: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
onTogglePauseClick: vi.fn(),
|
||||
pausingModelId: null,
|
||||
};
|
||||
|
||||
const row = (modelId: string): HTMLElement => {
|
||||
const element = document.querySelector(`[data-row-id="${modelId}"]`);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`row ${modelId} not rendered`);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
describe("AllModelsTable", () => {
|
||||
it("renders the nine design columns and hides Status behind the Columns menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTable {...baseProps} />);
|
||||
|
||||
for (const header of [
|
||||
"Model ID",
|
||||
"Model Information",
|
||||
"Credentials",
|
||||
"Created By",
|
||||
"Updated At",
|
||||
"Costs",
|
||||
"Team ID",
|
||||
"Model Access Group",
|
||||
"Actions",
|
||||
]) {
|
||||
expect(screen.getByRole("columnheader", { name: new RegExp(header, "i") })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(screen.queryByRole("columnheader", { name: /^status$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("DB Model")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /columns/i }));
|
||||
await user.click(await screen.findByRole("menuitemcheckbox", { name: /status/i }));
|
||||
|
||||
expect(await screen.findByText("DB Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the model detail from the model ID cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onModelIdClick = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} onModelIdClick={onModelIdClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("model-id-model-1"));
|
||||
|
||||
expect(onModelIdClick).toHaveBeenCalledWith("model-1");
|
||||
});
|
||||
|
||||
it("opens the team detail from the team ID cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTeamIdClick = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} onTeamIdClick={onTeamIdClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("model-team-id-model-1"));
|
||||
|
||||
expect(onTeamIdClick).toHaveBeenCalledWith("team-1");
|
||||
});
|
||||
|
||||
it("shows a dash when the model has no team", () => {
|
||||
render(
|
||||
<AllModelsTable {...baseProps} data={[makeModel({ model_info: { team_id: "" } as ModelData["model_info"] })]} />,
|
||||
);
|
||||
|
||||
expect(within(row("model-1")).getAllByText("-").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByTestId("model-team-id-model-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the model name over the litellm model name", () => {
|
||||
render(<AllModelsTable {...baseProps} />);
|
||||
|
||||
const cell = screen.getByTestId("model-information-model-1");
|
||||
expect(within(cell).getByText("gpt-4-public")).toBeInTheDocument();
|
||||
expect(within(cell).getByText("openai/gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a reusable credential by name and falls back to Manual", () => {
|
||||
const { rerender } = render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[makeModel({ litellm_params: { model: "openai/gpt-4", litellm_credential_name: "openai-prod" } })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("openai-prod")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Manual")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AllModelsTable {...baseProps} data={[makeModel()]} />);
|
||||
expect(screen.getByText("Manual")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Defined in config' for a config model and the creator for a DB model", () => {
|
||||
const { rerender } = render(<AllModelsTable {...baseProps} />);
|
||||
expect(screen.getByText("alice")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[makeModel({ model_info: { db_model: false } as ModelData["model_info"] })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Defined in config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders input and output costs and a dash when both are missing", () => {
|
||||
const { rerender } = render(<AllModelsTable {...baseProps} />);
|
||||
expect(screen.getByText("$30")).toBeInTheDocument();
|
||||
expect(screen.getByText("$60")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[makeModel({ input_cost: null as unknown as number, output_cost: null as unknown as number })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/^\$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses extra access groups behind a +N more badge", () => {
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[
|
||||
makeModel({
|
||||
model_info: { access_groups: ["sales-team", "eng-team", "growth"] } as ModelData["model_info"],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("sales-team")).toBeInTheDocument();
|
||||
expect(screen.getByText("+2 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("pause / resume", () => {
|
||||
it("renders the toggle on for an active DB model and off for a blocked one", () => {
|
||||
const { rerender } = render(<AllModelsTable {...baseProps} />);
|
||||
expect(screen.getByTestId("model-pause-toggle-model-1")).toBeChecked();
|
||||
|
||||
rerender(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[makeModel({ model_info: { blocked: true } as ModelData["model_info"] })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("model-pause-toggle-model-1")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("pauses an active model and resumes a blocked one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePauseClick = vi.fn();
|
||||
const { rerender } = render(<AllModelsTable {...baseProps} onTogglePauseClick={onTogglePauseClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("model-pause-toggle-model-1"));
|
||||
expect(onTogglePauseClick).toHaveBeenCalledWith("model-1", true);
|
||||
|
||||
onTogglePauseClick.mockClear();
|
||||
rerender(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
onTogglePauseClick={onTogglePauseClick}
|
||||
data={[makeModel({ model_info: { blocked: true } as ModelData["model_info"] })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("model-pause-toggle-model-1"));
|
||||
expect(onTogglePauseClick).toHaveBeenCalledWith("model-1", false);
|
||||
});
|
||||
|
||||
it("does not let a non-admin toggle a model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePauseClick = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} userRole="Internal User" onTogglePauseClick={onTogglePauseClick} />);
|
||||
|
||||
const toggle = screen.getByTestId("model-pause-toggle-model-1");
|
||||
expect(toggle).toHaveAttribute("data-disabled");
|
||||
await user.click(toggle);
|
||||
expect(onTogglePauseClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let anyone toggle a config model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePauseClick = vi.fn();
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
onTogglePauseClick={onTogglePauseClick}
|
||||
data={[makeModel({ model_info: { db_model: false } as ModelData["model_info"] })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByTestId("model-pause-toggle-model-1");
|
||||
expect(toggle).toHaveAttribute("data-disabled");
|
||||
await user.click(toggle);
|
||||
expect(onTogglePauseClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces the toggle with a pending indicator while a PATCH is in flight", () => {
|
||||
render(<AllModelsTable {...baseProps} pausingModelId="model-1" />);
|
||||
|
||||
expect(screen.getByTestId("model-pause-pending-model-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("model-pause-toggle-model-1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("lets an admin delete a DB model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} userID="someone-else" onDeleteClick={onDeleteClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("model-delete-model-1"));
|
||||
expect(onDeleteClick).toHaveBeenCalledWith("model-1");
|
||||
});
|
||||
|
||||
it("lets the creator delete their own DB model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} userRole="Internal User" userID="alice" onDeleteClick={onDeleteClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("model-delete-model-1"));
|
||||
expect(onDeleteClick).toHaveBeenCalledWith("model-1");
|
||||
});
|
||||
|
||||
it("blocks deleting a model the user did not create", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} userRole="Internal User" userID="bob" onDeleteClick={onDeleteClick} />);
|
||||
|
||||
const deleteButton = screen.getByTestId("model-delete-model-1");
|
||||
expect(deleteButton).toBeDisabled();
|
||||
await user.click(deleteButton);
|
||||
expect(onDeleteClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks deleting a config model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
onDeleteClick={onDeleteClick}
|
||||
data={[makeModel({ model_info: { db_model: false } as ModelData["model_info"] })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId("model-delete-model-1");
|
||||
expect(deleteButton).toBeDisabled();
|
||||
await user.click(deleteButton);
|
||||
expect(onDeleteClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolbar", () => {
|
||||
it("wires search, refresh, team, view and model settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
const onRefresh = vi.fn();
|
||||
const onOpenModelSettings = vi.fn();
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
onSearchChange={onSearchChange}
|
||||
onRefresh={onRefresh}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "gpt");
|
||||
expect(onSearchChange).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("datatable-refresh"));
|
||||
expect(onRefresh).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("models-settings-trigger"));
|
||||
expect(onOpenModelSettings).toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByTestId("models-team-select")).toHaveTextContent("Personal");
|
||||
expect(screen.getByTestId("models-view-select")).toHaveTextContent("Current Team Models");
|
||||
});
|
||||
|
||||
it("switches the current team", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTeamChange = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} onTeamChange={onTeamChange} />);
|
||||
|
||||
await user.click(screen.getByTestId("models-team-select"));
|
||||
await user.click(await screen.findByRole("option", { name: "Engineering" }));
|
||||
|
||||
expect(onTeamChange).toHaveBeenCalledWith("team-1");
|
||||
});
|
||||
|
||||
it("runs the full reset from the filter drawer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResetFilters = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} onResetFilters={onResetFilters} />);
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await user.click(await screen.findByTestId("filter-drawer-reset"));
|
||||
|
||||
expect(onResetFilters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders active filters as removable chips", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onColumnFiltersChange = vi.fn();
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
columnFilters={[{ id: "model_name", value: "wildcard" }]}
|
||||
onColumnFiltersChange={onColumnFiltersChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("filter-chip-model_name");
|
||||
expect(chip).toHaveTextContent("Public Model Name");
|
||||
expect(chip).toHaveTextContent("Wildcard Models (*)");
|
||||
|
||||
await user.click(screen.getByTestId("filter-chip-remove-model_name"));
|
||||
expect(onColumnFiltersChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the server row count in the pagination footer", () => {
|
||||
render(<AllModelsTable {...baseProps} rowCount={137} />);
|
||||
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137");
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no models", () => {
|
||||
render(<AllModelsTable {...baseProps} data={[]} rowCount={0} />);
|
||||
|
||||
expect(screen.getByText("No models found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Search, Settings } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import {
|
||||
ACCESS_GROUPS_COLUMN_ID,
|
||||
getModelsTableColumns,
|
||||
MODEL_NAME_COLUMN_ID,
|
||||
STATUS_COLUMN_ID,
|
||||
} from "./ModelsTableColumns";
|
||||
|
||||
export type ModelViewMode = "all" | "current_team";
|
||||
|
||||
export const PERSONAL_TEAM_VALUE = "personal";
|
||||
export const ALL_MODEL_GROUPS_VALUE = "all";
|
||||
export const WILDCARD_MODEL_GROUP_VALUE = "wildcard";
|
||||
|
||||
const MODEL_TABLE_BODY_HEIGHT = 600;
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
[MODEL_NAME_COLUMN_ID]: "Public Model Name",
|
||||
[ACCESS_GROUPS_COLUMN_ID]: "Model Access Group",
|
||||
};
|
||||
|
||||
const VIEW_MODE_LABELS: Record<ModelViewMode, string> = {
|
||||
current_team: "Current Team Models",
|
||||
all: "All Available Models",
|
||||
};
|
||||
|
||||
export interface ModelsTableTeamOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AllModelsTableProps {
|
||||
data: ModelData[];
|
||||
rowCount: number;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
sorting: SortingState;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
columnFilters: ColumnFiltersState;
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
|
||||
onResetFilters: () => void;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
teamOptions: ModelsTableTeamOption[];
|
||||
selectedTeamValue: string;
|
||||
onTeamChange: (value: string) => void;
|
||||
isLoadingTeams: boolean;
|
||||
viewMode: ModelViewMode;
|
||||
onViewModeChange: (viewMode: ModelViewMode) => void;
|
||||
onOpenModelSettings: () => void;
|
||||
availableModelGroups: string[];
|
||||
availableModelAccessGroups: string[];
|
||||
userRole: string;
|
||||
userID: string;
|
||||
onModelIdClick: (modelId: string) => void;
|
||||
onTeamIdClick: (teamId: string) => void;
|
||||
onDeleteClick: (modelId: string) => void;
|
||||
onTogglePauseClick: (modelId: string, blocked: boolean) => void | Promise<void>;
|
||||
pausingModelId: string | null;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||
<Search className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-base font-semibold text-foreground">No models found</div>
|
||||
<div className="max-w-80 text-sm text-muted-foreground">
|
||||
No models match your search or filters. Try resetting them.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllModelsTable({
|
||||
data,
|
||||
rowCount,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
onResetFilters,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
teamOptions,
|
||||
selectedTeamValue,
|
||||
onTeamChange,
|
||||
isLoadingTeams,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onOpenModelSettings,
|
||||
availableModelGroups,
|
||||
availableModelAccessGroups,
|
||||
userRole,
|
||||
userID,
|
||||
onModelIdClick,
|
||||
onTeamIdClick,
|
||||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
}: AllModelsTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = {
|
||||
userRole,
|
||||
userID,
|
||||
onModelIdClick,
|
||||
onTeamIdClick,
|
||||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
};
|
||||
return getModelsTableColumns(columnDeps);
|
||||
}, [userRole, userID, onModelIdClick, onTeamIdClick, onDeleteClick, onTogglePauseClick, pausingModelId]);
|
||||
|
||||
const modelGroupOptions = useMemo(
|
||||
() => [
|
||||
{ label: "All Models", value: ALL_MODEL_GROUPS_VALUE },
|
||||
{ label: "Wildcard Models (*)", value: WILDCARD_MODEL_GROUP_VALUE },
|
||||
...availableModelGroups.map((group) => ({ label: group, value: group })),
|
||||
],
|
||||
[availableModelGroups],
|
||||
);
|
||||
|
||||
const accessGroupOptions = useMemo(
|
||||
() => [
|
||||
{ label: "All Model Access Groups", value: ALL_MODEL_GROUPS_VALUE },
|
||||
...availableModelAccessGroups.map((accessGroup) => ({ label: accessGroup, value: accessGroup })),
|
||||
],
|
||||
[availableModelAccessGroups],
|
||||
);
|
||||
|
||||
const formatFilterValue = (columnId: string, value: unknown): string => {
|
||||
const raw = String(value);
|
||||
if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) {
|
||||
return "Wildcard Models (*)";
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
const selectedTeamLabel =
|
||||
teamOptions.find((option) => option.value === selectedTeamValue)?.label ?? teamOptions[0]?.label ?? "";
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row, index) => row.model_info?.id ?? String(index)}
|
||||
sortingMode="server"
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
enableSortingRemoval
|
||||
paginationMode="server"
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
rowCount={rowCount}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
filterMode="server"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={onColumnFiltersChange}
|
||||
defaultColumnVisibility={{ [STATUS_COLUMN_ID]: false }}
|
||||
enableColumnResizing
|
||||
maxBodyHeight={MODEL_TABLE_BODY_HEIGHT}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading models…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search model names…"
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
filterLabels={FILTER_LABELS}
|
||||
formatFilterValue={formatFilterValue}
|
||||
>
|
||||
<Select value={selectedTeamValue} onValueChange={(value) => onTeamChange(String(value))}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label="Current team"
|
||||
data-testid="models-team-select"
|
||||
className="gap-2 bg-secondary"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full",
|
||||
selectedTeamValue === PERSONAL_TEAM_VALUE ? "bg-blue-500" : "bg-green-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">Team</span>
|
||||
<span className="truncate font-semibold">{selectedTeamLabel}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teamOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} disabled={isLoadingTeams}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={viewMode} onValueChange={(value) => onViewModeChange(value as ModelViewMode)}>
|
||||
<SelectTrigger size="sm" aria-label="View" data-testid="models-view-select" className="gap-2">
|
||||
<span className="text-muted-foreground">View</span>
|
||||
<span className="truncate">{VIEW_MODE_LABELS[viewMode]}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current_team">{VIEW_MODE_LABELS.current_team}</SelectItem>
|
||||
<SelectItem value="all">{VIEW_MODE_LABELS.all}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Model Settings"
|
||||
title="Model Settings"
|
||||
data-testid="models-settings-trigger"
|
||||
onClick={onOpenModelSettings}
|
||||
>
|
||||
<Settings />
|
||||
</Button>
|
||||
</DataTableToolbar>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down models + endpoints"
|
||||
resetLabel="Reset Filters"
|
||||
onReset={onResetFilters}
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Public Model Name">
|
||||
<SearchSelect
|
||||
options={modelGroupOptions}
|
||||
value={(get(MODEL_NAME_COLUMN_ID) as string) ?? ALL_MODEL_GROUPS_VALUE}
|
||||
onValueChange={(value) =>
|
||||
set(MODEL_NAME_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value)
|
||||
}
|
||||
placeholder="Filter by Public Model Name"
|
||||
emptyText="No models found"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Model Access Group">
|
||||
<SearchSelect
|
||||
options={accessGroupOptions}
|
||||
value={(get(ACCESS_GROUPS_COLUMN_ID) as string) ?? ALL_MODEL_GROUPS_VALUE}
|
||||
onValueChange={(value) =>
|
||||
set(ACCESS_GROUPS_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value)
|
||||
}
|
||||
placeholder="Filter by Model Access Group"
|
||||
emptyText="No model access groups found"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, formatCellDate, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
export const MODEL_ID_COLUMN_ID = "model_info_id";
|
||||
export const MODEL_NAME_COLUMN_ID = "model_name";
|
||||
export const CREDENTIALS_COLUMN_ID = "litellm_credential_name";
|
||||
export const CREATED_BY_COLUMN_ID = "model_info_created_by";
|
||||
export const UPDATED_AT_COLUMN_ID = "model_info_updated_at";
|
||||
export const COSTS_COLUMN_ID = "input_cost";
|
||||
export const TEAM_ID_COLUMN_ID = "model_info_team_id";
|
||||
export const ACCESS_GROUPS_COLUMN_ID = "model_info_access_groups";
|
||||
export const STATUS_COLUMN_ID = "model_info_db_model";
|
||||
|
||||
const COLUMN_ID_TO_SERVER_SORT_FIELD: Record<string, string> = {
|
||||
[COSTS_COLUMN_ID]: "costs",
|
||||
[STATUS_COLUMN_ID]: "status",
|
||||
[CREATED_BY_COLUMN_ID]: "created_at",
|
||||
[UPDATED_AT_COLUMN_ID]: "updated_at",
|
||||
};
|
||||
|
||||
export const toServerSortField = (columnId: string): string => COLUMN_ID_TO_SERVER_SORT_FIELD[columnId] ?? columnId;
|
||||
|
||||
const formatShortDate = (value: string | null | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : formatCellDate(date, "date");
|
||||
};
|
||||
|
||||
function ModelInformationCell({ model, displayName }: { model: ModelData; displayName: string }) {
|
||||
const litellmModelName = model.litellm_model_name || "-";
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
render={
|
||||
<div className="flex min-w-0 items-center gap-2.5" data-testid={`model-information-${model.model_info.id}`} />
|
||||
}
|
||||
>
|
||||
{model.provider ? (
|
||||
<ProviderLogo provider={model.provider} className="size-6 shrink-0" />
|
||||
) : (
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="max-w-60 truncate text-sm font-medium text-foreground" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="max-w-60 truncate font-mono text-xs text-muted-foreground" title={litellmModelName}>
|
||||
{litellmModelName}
|
||||
</span>
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-80">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{model.provider ? <ProviderLogo provider={model.provider} className="size-4 shrink-0" /> : null}
|
||||
<span className="truncate text-xs text-muted-foreground">{model.provider || "Unknown provider"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Public Model Name</span>
|
||||
<span className="truncate text-sm font-medium text-foreground" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">LiteLLM Model Name</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate font-mono text-sm text-foreground" title={litellmModelName}>
|
||||
{litellmModelName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy LiteLLM model name"
|
||||
data-testid={`copy-litellm-model-name-${model.model_info.id}`}
|
||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void copyToClipboard(litellmModelName, "LiteLLM model name copied")}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsHeader() {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
Credentials
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About credential types"
|
||||
data-testid="credentials-header-info"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-80">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-foreground">Credential types</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-blue-600">
|
||||
<RefreshCw className="size-3.5" />
|
||||
Reusable
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Credentials saved in LiteLLM that can be added to models repeatedly.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Pencil className="size-3.5" />
|
||||
Manual
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Credentials added directly during model creation or defined in the config file.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsCell({ credentialName }: { credentialName: string | undefined }) {
|
||||
if (!credentialName) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 font-normal text-muted-foreground">
|
||||
<Pencil className="size-3" />
|
||||
Manual
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-blue-600" title={credentialName}>
|
||||
<RefreshCw className="size-3 shrink-0" />
|
||||
<span className="truncate">{credentialName}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatedByCell({ model }: { model: ModelData }) {
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const createdAt = formatShortDate(model.model_info.created_at);
|
||||
const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown";
|
||||
const secondaryForDbModel = createdAt ?? "Unknown date";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="max-w-44 truncate text-sm text-foreground" title={primary}>
|
||||
{primary}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{isConfigModel ? "-" : secondaryForDbModel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostsCell({ model }: { model: ModelData }) {
|
||||
const { input_cost: inputCost, output_cost: outputCost } = model;
|
||||
|
||||
if (inputCost == null && outputCost == null) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CellTooltip
|
||||
content="Cost per 1M tokens"
|
||||
trigger={
|
||||
<div className="flex flex-col gap-0.5 whitespace-nowrap">
|
||||
{inputCost != null && (
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-semibold tracking-wider text-muted-foreground">IN</span>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground">${inputCost}</span>
|
||||
</span>
|
||||
)}
|
||||
{outputCost != null && (
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-semibold tracking-wider text-muted-foreground">OUT</span>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground">${outputCost}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) {
|
||||
if (!accessGroups || accessGroups.length === 0) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const [first, ...overflow] = accessGroups;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Badge variant="outline" className="max-w-36 truncate border-blue-200 bg-blue-50 font-normal text-blue-600">
|
||||
{first}
|
||||
</Badge>
|
||||
{overflow.length > 0 && (
|
||||
<CellTooltip
|
||||
content={
|
||||
<div className="flex max-w-[280px] flex-col gap-0.5">
|
||||
{overflow.map((group) => (
|
||||
<span key={group}>{group}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
trigger={
|
||||
<Badge variant="outline" className="shrink-0 cursor-default font-normal">
|
||||
+{overflow.length} more
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelRowActionsProps {
|
||||
model: ModelData;
|
||||
userRole: string;
|
||||
userID: string;
|
||||
isPausing: boolean;
|
||||
onDeleteClick?: (modelId: string) => void;
|
||||
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function ModelRowActions({
|
||||
model,
|
||||
userRole,
|
||||
userID,
|
||||
isPausing,
|
||||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
}: ModelRowActionsProps) {
|
||||
const modelId = model.model_info?.id;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const isAdmin = userRole === "Admin";
|
||||
const canEditModel = isAdmin || model.model_info?.created_by === userID;
|
||||
const isBlocked = model.model_info?.blocked === true;
|
||||
const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick);
|
||||
|
||||
const resolvePauseTooltip = (): string => {
|
||||
if (isConfigModel) {
|
||||
return "Config models cannot be paused from the dashboard. Pause is DB-backed.";
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return "Only proxy admins can pause or resume a model.";
|
||||
}
|
||||
return isBlocked ? "Resume model — restore normal routing." : "Pause model — stop routing requests until resumed.";
|
||||
};
|
||||
|
||||
const deleteTooltip = isConfigModel
|
||||
? "Config model cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
: "Delete model";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span className="flex w-8 shrink-0 items-center justify-center">
|
||||
{isPausing ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin text-muted-foreground"
|
||||
data-testid={`model-pause-pending-${modelId}`}
|
||||
/>
|
||||
) : (
|
||||
<CellTooltip
|
||||
content={resolvePauseTooltip()}
|
||||
trigger={
|
||||
<span className="inline-flex">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={!isBlocked}
|
||||
disabled={!isPauseToggleable}
|
||||
aria-label={isBlocked ? "Resume model" : "Pause model"}
|
||||
data-testid={`model-pause-toggle-${modelId}`}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
if (isPauseToggleable && onTogglePauseClick && modelId) {
|
||||
void onTogglePauseClick(modelId, !nextChecked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<CellTooltip
|
||||
content={deleteTooltip}
|
||||
trigger={
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete model"
|
||||
data-testid={`model-delete-${modelId}`}
|
||||
disabled={isConfigModel || !canEditModel}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (onDeleteClick && modelId) {
|
||||
onDeleteClick(modelId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ModelsTableColumnDeps {
|
||||
userRole: string;
|
||||
userID: string;
|
||||
onModelIdClick: (modelId: string) => void;
|
||||
onTeamIdClick: (teamId: string) => void;
|
||||
onDeleteClick?: (modelId: string) => void;
|
||||
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>;
|
||||
pausingModelId?: string | null;
|
||||
}
|
||||
|
||||
export const getModelsTableColumns = ({
|
||||
userRole,
|
||||
userID,
|
||||
onModelIdClick,
|
||||
onTeamIdClick,
|
||||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
}: ModelsTableColumnDeps): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
id: MODEL_ID_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.id,
|
||||
meta: { title: "Model ID" },
|
||||
header: "Model ID",
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 90,
|
||||
cell: ({ row }) => (
|
||||
<IdCell
|
||||
value={row.original.model_info.id}
|
||||
onClick={onModelIdClick}
|
||||
dataTestId={`model-id-${row.original.model_info.id}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: MODEL_NAME_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_name ?? "",
|
||||
meta: { title: "Model Information", skeleton: "twoLine" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Model Information" />,
|
||||
enableSorting: true,
|
||||
size: 280,
|
||||
minSize: 160,
|
||||
cell: ({ row }) => (
|
||||
<ModelInformationCell model={row.original} displayName={getDisplayModelName(row.original) || "-"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: CREDENTIALS_COLUMN_ID,
|
||||
accessorFn: (row) => row.litellm_params?.litellm_credential_name ?? "",
|
||||
meta: { title: "Credentials" },
|
||||
header: () => <CredentialsHeader />,
|
||||
enableSorting: false,
|
||||
size: 180,
|
||||
minSize: 110,
|
||||
cell: ({ row }) => <CredentialsCell credentialName={row.original.litellm_params?.litellm_credential_name} />,
|
||||
},
|
||||
{
|
||||
id: CREATED_BY_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.created_by ?? "",
|
||||
meta: { title: "Created By", skeleton: "twoLine" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created By" />,
|
||||
enableSorting: true,
|
||||
size: 180,
|
||||
minSize: 110,
|
||||
cell: ({ row }) => <CreatedByCell model={row.original} />,
|
||||
},
|
||||
{
|
||||
id: UPDATED_AT_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.updated_at ?? "",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
enableSorting: true,
|
||||
size: 140,
|
||||
minSize: 100,
|
||||
cell: ({ row }) => <DateCell value={row.original.model_info.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: COSTS_COLUMN_ID,
|
||||
accessorFn: (row) => row.input_cost,
|
||||
meta: { title: "Costs" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Costs" />,
|
||||
enableSorting: true,
|
||||
size: 130,
|
||||
minSize: 90,
|
||||
cell: ({ row }) => <CostsCell model={row.original} />,
|
||||
},
|
||||
{
|
||||
id: TEAM_ID_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.team_id ?? "",
|
||||
meta: { title: "Team ID" },
|
||||
header: "Team ID",
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 90,
|
||||
cell: ({ row }) => (
|
||||
<IdCell
|
||||
value={row.original.model_info.team_id}
|
||||
onClick={onTeamIdClick}
|
||||
dataTestId={`model-team-id-${row.original.model_info.id}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: ACCESS_GROUPS_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.access_groups ?? [],
|
||||
meta: { title: "Model Access Group", skeleton: "chips" },
|
||||
header: "Model Access Group",
|
||||
enableSorting: false,
|
||||
size: 200,
|
||||
minSize: 120,
|
||||
cell: ({ row }) => <AccessGroupsCell accessGroups={row.original.model_info.access_groups} />,
|
||||
},
|
||||
{
|
||||
id: STATUS_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.db_model,
|
||||
meta: { title: "Status", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
|
||||
enableSorting: true,
|
||||
size: 140,
|
||||
minSize: 100,
|
||||
cell: ({ row }) =>
|
||||
row.original.model_info.db_model ? (
|
||||
<StatusBadge tone="info" label="DB Model" />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Config Model" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { title: "Actions", className: "text-right", headerClassName: "text-right" },
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
size: 110,
|
||||
minSize: 110,
|
||||
cell: ({ row }) => (
|
||||
<ModelRowActions
|
||||
model={row.original}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
isPausing={pausingModelId === row.original.model_info?.id}
|
||||
onDeleteClick={onDeleteClick}
|
||||
onTogglePauseClick={onTogglePauseClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
ColumnResizeMode,
|
||||
VisibilityState,
|
||||
PaginationState,
|
||||
OnChangeFn,
|
||||
} from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import {
|
||||
TableHeaderSortDropdown,
|
||||
SortState,
|
||||
} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
|
||||
// Extend the column meta type to include className
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData, TValue> {
|
||||
className?: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface AllModelsDataTableProps<TData, TValue> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
pagination?: PaginationState;
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
enablePagination?: boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
export function AllModelsDataTable<TData, TValue>({
|
||||
data = [],
|
||||
columns,
|
||||
isLoading = false,
|
||||
sorting = [],
|
||||
onSortingChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
enablePagination = false,
|
||||
onRowClick,
|
||||
}: AllModelsDataTableProps<TData, TValue>) {
|
||||
const [columnResizeMode] = React.useState<ColumnResizeMode>("onChange");
|
||||
const [columnSizing, setColumnSizing] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
|
||||
const tableInstance = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnSizing,
|
||||
columnVisibility,
|
||||
...(enablePagination && pagination ? { pagination } : {}),
|
||||
},
|
||||
columnResizeMode,
|
||||
onSortingChange: onSortingChange,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
...(enablePagination && onPaginationChange ? { onPaginationChange } : {}),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// NO getSortedRowModel - sorting is handled server-side
|
||||
...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
enableSorting: true,
|
||||
enableColumnResizing: true,
|
||||
manualSorting: true, // Enable manual sorting for server-side sorting
|
||||
defaultColumn: {
|
||||
minSize: 40,
|
||||
maxSize: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const getHeaderText = (header: any): string => {
|
||||
if (typeof header === "string") {
|
||||
return header;
|
||||
}
|
||||
if (typeof header === "function") {
|
||||
const headerElement = header();
|
||||
if (headerElement && headerElement.props && headerElement.props.children) {
|
||||
const children = headerElement.props.children;
|
||||
if (typeof children === "string") {
|
||||
return children;
|
||||
}
|
||||
if (children.props && children.props.children) {
|
||||
return children.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="relative min-w-full">
|
||||
<Table
|
||||
className="[&_td]:py-2 [&_th]:py-2"
|
||||
style={{
|
||||
width: tableInstance.getTotalSize(),
|
||||
minWidth: "100%",
|
||||
tableLayout: "fixed",
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
{tableInstance.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 relative ${
|
||||
header.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
|
||||
: ""
|
||||
} ${header.column.columnDef.meta?.className || ""}`}
|
||||
style={{
|
||||
width: header.id === "actions" ? 120 : header.getSize(),
|
||||
position: header.id === "actions" ? "sticky" : "relative",
|
||||
right: header.id === "actions" ? 0 : "auto",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{header.id !== "actions" && header.column.getCanSort() && onSortingChange && (
|
||||
<TableHeaderSortDropdown
|
||||
sortState={
|
||||
header.column.getIsSorted() === false ? false : (header.column.getIsSorted() as SortState)
|
||||
}
|
||||
onSortChange={(newState) => {
|
||||
// Convert SortState to TanStack SortingState
|
||||
// Only allow one column to be sorted at a time
|
||||
if (newState === false) {
|
||||
onSortingChange([]);
|
||||
} else {
|
||||
onSortingChange([
|
||||
{
|
||||
id: header.column.id,
|
||||
desc: newState === "desc",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}}
|
||||
columnId={header.column.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none ${
|
||||
header.column.getIsResizing() ? "bg-blue-500" : "hover:bg-blue-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading models...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : tableInstance.getRowModel().rows.length > 0 ? (
|
||||
tableInstance.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 overflow-hidden ${
|
||||
cell.column.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
|
||||
: ""
|
||||
} ${cell.column.columnDef.meta?.className || ""}`}
|
||||
style={{
|
||||
width: cell.column.id === "actions" ? 120 : cell.column.getSize(),
|
||||
position: cell.column.id === "actions" ? "sticky" : "relative",
|
||||
right: cell.column.id === "actions" ? 0 : "auto",
|
||||
}}
|
||||
>
|
||||
{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 models found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export interface ModelInfo {
|
|||
db_model: boolean;
|
||||
access_groups: string[] | null;
|
||||
blocked?: boolean;
|
||||
team_public_model_name?: string;
|
||||
}
|
||||
|
||||
export interface LiteLLMParams {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,423 +0,0 @@
|
|||
import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Icon } from "@tremor/react";
|
||||
import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
|
||||
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { ModelData } from "../../model_dashboard/types";
|
||||
import { ProviderLogo } from "./ProviderLogo";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const credentialsInfoPopoverContent = (
|
||||
<Space direction="vertical" size={12}>
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
Credential types
|
||||
</Text>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Flex align="center" gap={8}>
|
||||
<Space direction="vertical">
|
||||
<Flex align="center" gap={8}>
|
||||
<SyncOutlined style={{ color: "#1890ff" }} />
|
||||
<Title level={5} style={{ margin: 0, color: "#1890ff" }}>
|
||||
Reusable
|
||||
</Title>
|
||||
</Flex>
|
||||
<Text type="secondary">Credentials saved in LiteLLM that can be added to models repeatedly.</Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
<Divider size="small" />
|
||||
<Flex align="center" gap={8}>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Flex align="center" gap={8}>
|
||||
<EditOutlined style={{ color: "#8c8c8c", fontSize: 14, flexShrink: 0 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Manual
|
||||
</Title>
|
||||
</Flex>
|
||||
<Text type="secondary">Credentials added directly during model creation or defined in the config file.</Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
|
||||
export const columns = (
|
||||
userRole: string,
|
||||
userID: string,
|
||||
premiumUser: boolean,
|
||||
setSelectedModelId: (id: string) => void,
|
||||
setSelectedTeamId: (id: string) => void,
|
||||
getDisplayModelName: (model: any) => string,
|
||||
handleEditClick: (model: any) => void,
|
||||
handleRefreshClick: () => void,
|
||||
expandedRows: Set<string>,
|
||||
setExpandedRows: (expandedRows: Set<string>) => void,
|
||||
onDeleteClick?: (modelId: string) => void,
|
||||
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>,
|
||||
pausingModelId?: string | null,
|
||||
): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model ID</span>,
|
||||
accessorKey: "model_info.id",
|
||||
enableSorting: false,
|
||||
size: 130,
|
||||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<IdCell value={model.model_info.id} onClick={setSelectedModelId} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model Information</span>,
|
||||
accessorKey: "model_name",
|
||||
size: 250,
|
||||
minSize: 120,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const displayName = getDisplayModelName(row.original) || "-";
|
||||
const popoverContent = (
|
||||
<Space direction="vertical" size={12} style={{ minWidth: 220 }}>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo provider={model.provider} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{model.provider || "Unknown provider"}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Space direction="vertical" size={6}>
|
||||
<Space direction="vertical" size={2} style={{ width: "100%" }}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
Public Model Name
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 13, maxWidth: 480 }} ellipsis title={displayName}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
LiteLLM Model Name
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 13 }}
|
||||
copyable={{ text: model.litellm_model_name || "-" }}
|
||||
ellipsis
|
||||
title={model.litellm_model_name || "-"}
|
||||
>
|
||||
{model.litellm_model_name || "-"}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
placement="right"
|
||||
arrow={{ pointAtCenter: true }}
|
||||
styles={{
|
||||
root: {
|
||||
maxWidth: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-2 min-w-0 w-full cursor-pointer">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{model.provider ? (
|
||||
<ProviderLogo provider={model.provider} />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<Text ellipsis className="text-gray-900" style={{ fontSize: 12, fontWeight: 500, lineHeight: "16px" }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text ellipsis type="secondary" style={{ fontSize: 12, lineHeight: "16px", marginTop: 2 }}>
|
||||
{model.litellm_model_name || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-sm font-semibold">Credentials</span>
|
||||
<Popover content={credentialsInfoPopoverContent} placement="bottom" arrow={{ pointAtCenter: true }}>
|
||||
<InfoCircleOutlined className="cursor-pointer text-gray-400 hover:text-gray-600" style={{ fontSize: 12 }} />
|
||||
</Popover>
|
||||
</span>
|
||||
),
|
||||
accessorKey: "litellm_credential_name",
|
||||
enableSorting: false,
|
||||
size: 180,
|
||||
minSize: 100,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const credentialName = model.litellm_params?.litellm_credential_name;
|
||||
const isReusable = !!credentialName;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 min-w-0 w-full">
|
||||
{isReusable ? (
|
||||
<>
|
||||
<SyncOutlined className="shrink-0" style={{ color: "#1890ff", fontSize: 14 }} />
|
||||
<span className="text-xs truncate text-blue-600" title={credentialName}>
|
||||
{credentialName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EditOutlined className="shrink-0" style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<span className="text-xs text-gray-500">Manual</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Created By</span>,
|
||||
accessorKey: "model_info.created_by",
|
||||
sortingFn: "datetime",
|
||||
size: 160,
|
||||
minSize: 100,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const createdBy = model.model_info.created_by;
|
||||
const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 w-full">
|
||||
{/* Created By - Primary */}
|
||||
<div
|
||||
className="text-xs font-medium text-gray-900 truncate"
|
||||
title={isConfigModel ? "Defined in config" : createdBy || "Unknown"}
|
||||
>
|
||||
{isConfigModel ? "Defined in config" : createdBy || "Unknown"}
|
||||
</div>
|
||||
{/* Created At - Secondary */}
|
||||
<div
|
||||
className="text-xs text-gray-500 truncate mt-0.5"
|
||||
title={isConfigModel ? "Config file" : createdAt || "Unknown date"}
|
||||
>
|
||||
{isConfigModel ? "-" : createdAt || "Unknown date"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Updated At</span>,
|
||||
accessorKey: "model_info.updated_at",
|
||||
sortingFn: "datetime",
|
||||
size: 120,
|
||||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return <DateCell value={model.model_info.updated_at} precision="date" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Costs</span>,
|
||||
accessorKey: "input_cost",
|
||||
size: 120,
|
||||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const inputCost = model.input_cost;
|
||||
const outputCost = model.output_cost;
|
||||
|
||||
// If both costs are missing or undefined, show "-"
|
||||
if (inputCost == null && outputCost == null) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Cost per 1M tokens">
|
||||
<div className="flex flex-col min-w-0 w-full">
|
||||
{/* Input Cost - Primary */}
|
||||
{inputCost != null && <div className="text-xs font-medium text-gray-900 truncate">In: ${inputCost}</div>}
|
||||
{/* Output Cost - Secondary */}
|
||||
{outputCost != null && <div className="text-xs text-gray-500 truncate mt-0.5">Out: ${outputCost}</div>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Team ID</span>,
|
||||
accessorKey: "model_info.team_id",
|
||||
enableSorting: false,
|
||||
size: 130,
|
||||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return model.model_info.team_id ? (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<IdCell value={model.model_info.team_id} onClick={setSelectedTeamId} />
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model Access Group</span>,
|
||||
accessorKey: "model_info.model_access_group",
|
||||
enableSorting: false,
|
||||
size: 180,
|
||||
minSize: 100,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const accessGroups = model.model_info.access_groups;
|
||||
|
||||
if (!accessGroups || accessGroups.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const modelId = model.model_info.id;
|
||||
const isExpanded = expandedRows.has(modelId);
|
||||
const shouldShowExpandButton = accessGroups.length > 1;
|
||||
|
||||
const toggleExpanded = () => {
|
||||
const newExpanded = new Set(expandedRows);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(modelId);
|
||||
} else {
|
||||
newExpanded.add(modelId);
|
||||
}
|
||||
setExpandedRows(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-hidden w-full">
|
||||
<Badge size="xs" color="blue" className="text-xs px-1.5 py-0.5 h-5 leading-tight shrink-0">
|
||||
{accessGroups[0]}
|
||||
</Badge>
|
||||
|
||||
{(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) &&
|
||||
accessGroups.slice(1).map((group: string, index: number) => (
|
||||
<Badge
|
||||
key={index + 1}
|
||||
size="xs"
|
||||
color="blue"
|
||||
className="text-xs px-1.5 py-0.5 h-5 leading-tight shrink-0"
|
||||
>
|
||||
{group}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{shouldShowExpandButton && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded();
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 px-1 py-0.5 rounded-sm hover:bg-blue-50 h-5 leading-tight shrink-0 whitespace-nowrap"
|
||||
>
|
||||
{isExpanded ? "−" : `+${accessGroups.length - 1}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Status</span>,
|
||||
accessorKey: "model_info.db_model",
|
||||
size: 120,
|
||||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return model.model_info.db_model ? (
|
||||
<StatusBadge tone="info" label="DB Model" />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Config Model" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="text-sm font-semibold">Actions</span>,
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const isAdmin = userRole === "Admin";
|
||||
const isBlocked = model.model_info?.blocked === true;
|
||||
const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick);
|
||||
const pauseTooltip = isConfigModel
|
||||
? "Config models cannot be paused from the dashboard. Pause is DB-backed."
|
||||
: !isAdmin
|
||||
? "Only proxy admins can pause or resume a model."
|
||||
: isBlocked
|
||||
? "Resume model — restore normal routing."
|
||||
: "Pause model — stop routing requests until resumed.";
|
||||
// antd's `loading` prop on Switch is purely cosmetic — it does not block
|
||||
// clicks. Pair `loading` with `disabled` derived from the same condition
|
||||
// so a double-click during a pending PATCH cannot send a second,
|
||||
// conflicting `blocked` value.
|
||||
const isPausing = pausingModelId === model.model_info?.id;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 pr-4">
|
||||
<Tooltip title={pauseTooltip}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={!isBlocked}
|
||||
disabled={!isPauseToggleable || isPausing}
|
||||
loading={isPausing}
|
||||
aria-label={isBlocked ? "Resume model" : "Pause model"}
|
||||
onClick={(_, e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onChange={(nextChecked) => {
|
||||
const modelId = model.model_info?.id;
|
||||
if (isPauseToggleable && onTogglePauseClick && modelId) {
|
||||
void onTogglePauseClick(modelId, !nextChecked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isConfigModel ? (
|
||||
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
|
||||
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Delete model">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditModel && onDeleteClick) {
|
||||
onDeleteClick(model.model_info.id);
|
||||
}
|
||||
}}
|
||||
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -20,6 +20,8 @@ interface DataTableFilterDrawerProps<TData> {
|
|||
description?: React.ReactNode;
|
||||
applyLabel?: string;
|
||||
resetLabel?: string;
|
||||
/** Runs instead of the default "clear this table's column filters" when the reset button is pressed. */
|
||||
onReset?: () => void;
|
||||
children: (draft: FilterDraft) => React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +50,7 @@ export function DataTableFilterDrawer<TData>({
|
|||
description,
|
||||
applyLabel = "Apply Filters",
|
||||
resetLabel = "Reset",
|
||||
onReset,
|
||||
children,
|
||||
}: DataTableFilterDrawerProps<TData>) {
|
||||
const [draft, setDraft] = React.useState<Record<string, unknown>>(() => toDraft(table.getState().columnFilters));
|
||||
|
|
@ -72,6 +75,10 @@ export function DataTableFilterDrawer<TData>({
|
|||
|
||||
const reset = () => {
|
||||
setDraft({});
|
||||
if (onReset !== undefined) {
|
||||
onReset();
|
||||
return;
|
||||
}
|
||||
table.setColumnFilters([]);
|
||||
};
|
||||
|
||||
|
|
|
|||
46
ui/litellm-dashboard/src/components/ui/hover-card.tsx
Normal file
46
ui/litellm-dashboard/src/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
Loading…
Add table
Reference in a new issue