mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #34081 from BerriAI/litellm_/gallant-kapitsa-a809e0
refactor(ui): migrate organizations table onto shared DataTable
This commit is contained in:
commit
0b4851dd81
11 changed files with 807 additions and 585 deletions
|
|
@ -697,11 +697,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/organizations/_components/organizations.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ describe("OrganizationFilters", () => {
|
|||
const defaultFilters: FilterState = {
|
||||
org_id: "",
|
||||
org_alias: "",
|
||||
sort_by: "",
|
||||
sort_order: "asc",
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ interface OrganizationFiltersProps {
|
|||
type FilterState = {
|
||||
org_id: string;
|
||||
org_alias: string;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
};
|
||||
|
||||
const OrganizationFilters = ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
accessToken: null,
|
||||
userId: null,
|
||||
userRole: null,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./OrganizationsTable", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { isLoading: boolean }) => (
|
||||
<div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import OrganizationsPanel from "./OrganizationsPanel";
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
describe("OrganizationsPanel", () => {
|
||||
it("gates non-premium users behind the enterprise notice", () => {
|
||||
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={false} />);
|
||||
|
||||
expect(screen.getByText(/LiteLLM Enterprise feature/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("+ Create New Organization")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the create button for a premium admin", () => {
|
||||
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
|
||||
|
||||
expect(screen.getByText("+ Create New Organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves the loading skeleton to false when the query is disabled (no token)", () => {
|
||||
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
|
||||
|
||||
// A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton.
|
||||
expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { organizationCreateCall, organizationDeleteCall } from "@/components/networking";
|
||||
import OrganizationInfoView from "@/components/organization/organization_view";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
|
||||
import OrganizationsTable from "./OrganizationsTable";
|
||||
|
||||
interface OrganizationsPanelProps {
|
||||
userRole: string;
|
||||
accessToken: string | null;
|
||||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const [editOrg, setEditOrg] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>({ org_id: "", org_alias: "" });
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: organizations = [], isLoading } = useOrganizations({
|
||||
org_id: filters.org_id,
|
||||
org_alias: filters.org_alias,
|
||||
});
|
||||
const { data: userModels = [] } = useUserModels();
|
||||
|
||||
const searchActive = Boolean(filters.org_id || filters.org_alias);
|
||||
|
||||
const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() });
|
||||
|
||||
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||
setFilters((previousFilters) => ({ ...previousFilters, [key]: value }));
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
setFilters({ org_id: "", org_alias: "" });
|
||||
};
|
||||
|
||||
const handleDelete = (orgId: string | null) => {
|
||||
if (!orgId) return;
|
||||
|
||||
setOrgToDelete(orgId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!orgToDelete || !accessToken) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await organizationDeleteCall(accessToken, orgToDelete);
|
||||
NotificationsManager.success("Organization deleted successfully");
|
||||
|
||||
setIsDeleteModalOpen(false);
|
||||
setOrgToDelete(null);
|
||||
await refetchOrganizations();
|
||||
} catch (error) {
|
||||
console.error("Error deleting organization:", error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setOrgToDelete(null);
|
||||
};
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
||||
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission
|
||||
if (
|
||||
(values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) ||
|
||||
(values.allowed_mcp_servers_and_groups &&
|
||||
(values.allowed_mcp_servers_and_groups.servers?.length > 0 ||
|
||||
values.allowed_mcp_servers_and_groups.accessGroups?.length > 0))
|
||||
) {
|
||||
values.object_permission = {};
|
||||
if (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) {
|
||||
values.object_permission.vector_stores = values.allowed_vector_store_ids;
|
||||
delete values.allowed_vector_store_ids;
|
||||
}
|
||||
if (values.allowed_mcp_servers_and_groups) {
|
||||
if (values.allowed_mcp_servers_and_groups.servers?.length > 0) {
|
||||
values.object_permission.mcp_servers = values.allowed_mcp_servers_and_groups.servers;
|
||||
}
|
||||
if (values.allowed_mcp_servers_and_groups.accessGroups?.length > 0) {
|
||||
values.object_permission.mcp_access_groups = values.allowed_mcp_servers_and_groups.accessGroups;
|
||||
}
|
||||
delete values.allowed_mcp_servers_and_groups;
|
||||
}
|
||||
}
|
||||
|
||||
await organizationCreateCall(accessToken, values);
|
||||
NotificationsManager.success("Organization created successfully");
|
||||
setIsOrgModalVisible(false);
|
||||
form.resetFields();
|
||||
await refetchOrganizations();
|
||||
} catch (error) {
|
||||
console.error("Error creating organization:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOrgModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<div className="mx-4 mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "}
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-4 flex flex-col gap-4">
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<Button className="w-fit" onClick={() => setIsOrgModalVisible(true)}>
|
||||
+ Create New Organization
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedOrgId ? (
|
||||
<OrganizationInfoView
|
||||
organizationId={selectedOrgId}
|
||||
onClose={() => {
|
||||
setSelectedOrgId(null);
|
||||
setEditOrg(false);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
is_org_admin={true}
|
||||
is_proxy_admin={userRole === "Admin"}
|
||||
userModels={userModels}
|
||||
editOrg={editOrg}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Click on an organization ID to view its details.</p>
|
||||
<OrganizationFilters
|
||||
filters={filters}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={setShowFilters}
|
||||
onChange={handleFilterChange}
|
||||
onReset={handleFilterReset}
|
||||
/>
|
||||
<OrganizationsTable
|
||||
organizations={organizations}
|
||||
isLoading={isLoading}
|
||||
userRole={userRole}
|
||||
searchActive={searchActive}
|
||||
onOrganizationClick={setSelectedOrgId}
|
||||
onEditClick={(organizationId) => {
|
||||
setSelectedOrgId(organizationId);
|
||||
setEditOrg(true);
|
||||
}}
|
||||
onDeleteClick={handleDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal title="Create Organization" visible={isOrgModalVisible} width={800} footer={null} onCancel={handleCancel}>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<Form.Item
|
||||
label="Organization Name"
|
||||
name="organization_alias"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input an organization name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Models" name="models">
|
||||
<ModelSelect
|
||||
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
|
||||
value={form.getFieldValue("models")}
|
||||
onChange={(values) => form.setFieldValue("models", values)}
|
||||
context="organization"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<NumericalInput step={0.01} precision={2} width={200} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Reset Budget" name="budget_duration">
|
||||
<Select2 defaultValue={null} placeholder="n/a">
|
||||
<Select2.Option value="24h">daily</Select2.Option>
|
||||
<Select2.Option value="7d">weekly</Select2.Option>
|
||||
<Select2.Option value="30d">monthly</Select2.Option>
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Vector Stores{" "}
|
||||
<Tooltip title="Select which vector stores this organization can access by default. Leave empty for access to all vector stores">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_vector_store_ids"
|
||||
className="mt-4"
|
||||
help="Select vector stores this organization can access. Leave empty for access to all vector stores"
|
||||
>
|
||||
<VectorStoreSelector
|
||||
onChange={(values) => form.setFieldValue("allowed_vector_store_ids", values)}
|
||||
value={form.getFieldValue("allowed_vector_store_ids")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select vector stores (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers and access groups this organization can access by default.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
className="mt-4"
|
||||
help="Select MCP servers and access groups this organization can access."
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(values) => form.setFieldValue("allowed_mcp_servers_and_groups", values)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers and access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button type="submit">Create Organization</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Organization?"
|
||||
message="Are you sure you want to delete this organization? This action cannot be undone."
|
||||
resourceInformationTitle="Organization Information"
|
||||
resourceInformation={[{ label: "Organization ID", value: orgToDelete, code: true }]}
|
||||
onCancel={cancelDelete}
|
||||
onOk={confirmDelete}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationsPanel;
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Organization } from "@/components/networking";
|
||||
|
||||
import OrganizationsTable from "./OrganizationsTable";
|
||||
|
||||
const makeOrganization = (overrides: Partial<Organization> = {}): Organization => ({
|
||||
organization_id: "org-alpha",
|
||||
organization_alias: "Alpha",
|
||||
budget_id: "budget-1",
|
||||
metadata: {},
|
||||
models: [],
|
||||
spend: 0,
|
||||
model_spend: {},
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
created_by: "someone",
|
||||
updated_at: "2023-01-01T00:00:00Z",
|
||||
updated_by: "someone",
|
||||
litellm_budget_table: null,
|
||||
teams: null,
|
||||
users: null,
|
||||
members: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
isLoading: false,
|
||||
userRole: "Admin",
|
||||
searchActive: false,
|
||||
onOrganizationClick: vi.fn(),
|
||||
onEditClick: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
};
|
||||
|
||||
describe("OrganizationsTable", () => {
|
||||
it("renders every column header", () => {
|
||||
render(<OrganizationsTable {...baseProps} organizations={[]} />);
|
||||
for (const header of [
|
||||
"Organization ID",
|
||||
"Organization Name",
|
||||
"Created",
|
||||
"Spend (USD)",
|
||||
"Budget (USD)",
|
||||
"Models",
|
||||
"TPM / RPM Limits",
|
||||
"Members",
|
||||
]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the detail view when the organization ID cell is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOrganizationClick = vi.fn();
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
onOrganizationClick={onOrganizationClick}
|
||||
organizations={[makeOrganization({ organization_id: "org-123" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("org-123"));
|
||||
|
||||
expect(onOrganizationClick).toHaveBeenCalledWith("org-123");
|
||||
});
|
||||
|
||||
it("edits and deletes an organization through the ⋯ actions menu (admin)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditClick = vi.fn();
|
||||
const onDeleteClick = vi.fn();
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
userRole="Admin"
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
organizations={[makeOrganization({ organization_id: "org-9" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("organization-actions-org-9"));
|
||||
await user.click(await screen.findByTestId("organization-action-edit"));
|
||||
expect(onEditClick).toHaveBeenCalledWith("org-9");
|
||||
|
||||
await user.click(screen.getByTestId("organization-actions-org-9"));
|
||||
await user.click(await screen.findByTestId("organization-action-delete"));
|
||||
expect(onDeleteClick).toHaveBeenCalledWith("org-9");
|
||||
});
|
||||
|
||||
it("hides the row actions menu from non-admins", () => {
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
userRole="Internal User"
|
||||
organizations={[makeOrganization({ organization_id: "org-9" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("organization-actions-org-9")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts by created_at descending by default", () => {
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
organizations={[
|
||||
makeOrganization({
|
||||
organization_id: "org-old",
|
||||
organization_alias: "Older",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
makeOrganization({
|
||||
organization_id: "org-new",
|
||||
organization_alias: "Newer",
|
||||
created_at: "2024-06-01T00:00:00Z",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rows = screen.getAllByRole("row");
|
||||
// rows[0] is the header row; the newest organization must lead the body.
|
||||
expect(within(rows[1]).getByText("Newer")).toBeInTheDocument();
|
||||
expect(within(rows[2]).getByText("Older")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders budget, limits, members, and models for a fully-populated organization", () => {
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
organizations={[
|
||||
makeOrganization({
|
||||
litellm_budget_table: { max_budget: 100, tpm_limit: 1000, rpm_limit: 60 },
|
||||
members: [{ user_id: "a" }, { user_id: "b" }, { user_id: "c" }],
|
||||
models: ["gpt-4o", "claude-sonnet-4", "gemini-2.5-pro", "llama-3", "mistral-large"],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("TPM: 1000")).toBeInTheDocument();
|
||||
expect(screen.getByText("RPM: 60")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 Members")).toBeInTheDocument();
|
||||
// Five models, three visible -> the shared ModelsCell collapses the rest.
|
||||
expect(screen.getByText("+2 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Unlimited budget and All Proxy Models when unset", () => {
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
organizations={[makeOrganization({ organization_id: "org-empty", litellm_budget_table: {}, models: [] })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
// Budget shows a standalone "Unlimited"; the limits fall back inline.
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
expect(screen.getByText("TPM: Unlimited")).toBeInTheDocument();
|
||||
expect(screen.getByText("RPM: Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders loading skeletons instead of rows while loading", () => {
|
||||
render(
|
||||
<OrganizationsTable
|
||||
{...baseProps}
|
||||
isLoading
|
||||
organizations={[makeOrganization({ organization_alias: "ShouldNotShow" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("ShouldNotShow")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a search-aware empty state", () => {
|
||||
const { rerender } = render(<OrganizationsTable {...baseProps} searchActive={false} organizations={[]} />);
|
||||
expect(screen.getByText("No organizations yet")).toBeInTheDocument();
|
||||
|
||||
rerender(<OrganizationsTable {...baseProps} searchActive={true} organizations={[]} />);
|
||||
expect(screen.getByText("No matching organizations")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Building2, SearchX } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Organization } from "@/components/networking";
|
||||
|
||||
import { getOrganizationsTableColumns } from "./OrganizationsTableColumns";
|
||||
|
||||
interface OrganizationsTableProps {
|
||||
organizations: Organization[];
|
||||
isLoading: boolean;
|
||||
userRole: string;
|
||||
searchActive: boolean;
|
||||
onOrganizationClick: (organizationId: string) => void;
|
||||
onEditClick: (organizationId: string) => void;
|
||||
onDeleteClick: (organizationId: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState({ searchActive }: { searchActive: boolean }) {
|
||||
const Icon = searchActive ? SearchX : Building2;
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{searchActive ? "No matching organizations" : "No organizations yet"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{searchActive
|
||||
? "No organizations match your search. Try a different name or ID."
|
||||
: "Create an organization to group teams, models, and budgets."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
||||
organizations,
|
||||
isLoading,
|
||||
userRole,
|
||||
searchActive,
|
||||
onOrganizationClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick };
|
||||
return getOrganizationsTableColumns(deps);
|
||||
}, [userRole, onOrganizationClick, onEditClick, onDeleteClick]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={organizations}
|
||||
columns={columns}
|
||||
getRowId={(organization, index) => organization.organization_id || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading organizations…"
|
||||
noDataMessage={<EmptyState searchActive={searchActive} />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationsTable;
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Organization } from "@/components/networking";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface OrganizationBudget {
|
||||
max_budget?: number | null;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
}
|
||||
|
||||
const getOrganizationBudget = (organization: Organization): OrganizationBudget =>
|
||||
(organization.litellm_budget_table ?? {}) as OrganizationBudget;
|
||||
|
||||
function OrganizationLimitsCell({ organization }: { organization: Organization }) {
|
||||
const { tpm_limit, rpm_limit } = getOrganizationBudget(organization);
|
||||
return (
|
||||
<div className="flex flex-col text-xs text-muted-foreground">
|
||||
<span>TPM: {tpm_limit ? tpm_limit : "Unlimited"}</span>
|
||||
<span>RPM: {rpm_limit ? rpm_limit : "Unlimited"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OrganizationRowActionsProps {
|
||||
organization: Organization;
|
||||
onEditClick: (organizationId: string) => void;
|
||||
onDeleteClick: (organizationId: string) => void;
|
||||
}
|
||||
|
||||
function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: OrganizationRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open organization actions"
|
||||
data-testid={`organization-actions-${organization.organization_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
data-testid="organization-action-edit"
|
||||
onClick={() => onEditClick(organization.organization_id)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="organization-action-delete"
|
||||
onClick={() => onDeleteClick(organization.organization_id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OrganizationsTableColumnsDeps {
|
||||
userRole: string;
|
||||
onOrganizationClick: (organizationId: string) => void;
|
||||
onEditClick: (organizationId: string) => void;
|
||||
onDeleteClick: (organizationId: string) => void;
|
||||
}
|
||||
|
||||
export const getOrganizationsTableColumns = ({
|
||||
userRole,
|
||||
onOrganizationClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: OrganizationsTableColumnsDeps): ColumnDef<Organization>[] => [
|
||||
{
|
||||
id: "organization_id",
|
||||
accessorKey: "organization_id",
|
||||
meta: { title: "Organization ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Organization ID" />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.organization_id}
|
||||
titleClassName="font-mono text-xs font-normal"
|
||||
className="max-w-56"
|
||||
onClick={() => onOrganizationClick(row.original.organization_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "organization_alias",
|
||||
accessorKey: "organization_alias",
|
||||
meta: { title: "Organization Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Organization Name" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const alias = row.original.organization_alias;
|
||||
return (
|
||||
<span className="block max-w-56 truncate text-sm font-medium" title={alias ?? undefined}>
|
||||
{alias || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
meta: { title: "Budget (USD)" },
|
||||
header: "Budget (USD)",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<MoneyCell value={getOrganizationBudget(row.original).max_budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 260,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
},
|
||||
{
|
||||
id: "limits",
|
||||
meta: { title: "TPM / RPM Limits" },
|
||||
header: "TPM / RPM Limits",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <OrganizationLimitsCell organization={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
meta: { title: "Members" },
|
||||
header: "Members",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.members?.length ?? 0} Members</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) =>
|
||||
userRole === "Admin" ? (
|
||||
<div className="flex justify-end">
|
||||
<OrganizationRowActions organization={row.original} onEditClick={onEditClick} onDeleteClick={onDeleteClick} />
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
accessToken: null,
|
||||
userId: null,
|
||||
userRole: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import OrganizationsTable from "./organizations";
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
describe("OrganizationsTable", () => {
|
||||
it("should render the OrganizationsTable component", () => {
|
||||
const { getByText } = renderWithQueryClient(
|
||||
<OrganizationsTable userRole="Admin" accessToken={null} premiumUser={true} />,
|
||||
);
|
||||
|
||||
expect(getByText("+ Create New Organization")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Grid,
|
||||
Icon,
|
||||
Tab,
|
||||
TabGroup,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@tremor/react";
|
||||
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import {
|
||||
Organization,
|
||||
organizationCreateCall,
|
||||
organizationDeleteCall,
|
||||
organizationListCall,
|
||||
} from "@/components/networking";
|
||||
import OrganizationInfoView from "@/components/organization/organization_view";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
|
||||
interface OrganizationsTableProps {
|
||||
userRole: string;
|
||||
accessToken: string | null;
|
||||
lastRefreshed?: string;
|
||||
handleRefreshClick?: () => void;
|
||||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
export const fetchOrganizations = async (
|
||||
accessToken: string,
|
||||
setOrganizations: (organizations: Organization[]) => void,
|
||||
org_id: string | null = null,
|
||||
org_alias: string | null = null,
|
||||
) => {
|
||||
const organizations = await organizationListCall(accessToken, org_id, org_alias);
|
||||
setOrganizations(organizations);
|
||||
};
|
||||
|
||||
const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
||||
userRole,
|
||||
accessToken,
|
||||
lastRefreshed,
|
||||
handleRefreshClick,
|
||||
premiumUser,
|
||||
}) => {
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const [editOrg, setEditOrg] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
org_id: "",
|
||||
org_alias: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: organizations = [] } = useOrganizations({ org_id: filters.org_id, org_alias: filters.org_alias });
|
||||
const { data: userModels = [] } = useUserModels();
|
||||
|
||||
const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() });
|
||||
|
||||
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||
setFilters((previousFilters) => ({ ...previousFilters, [key]: value }));
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
setFilters({
|
||||
org_id: "",
|
||||
org_alias: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (orgId: string | null) => {
|
||||
if (!orgId) return;
|
||||
|
||||
setOrgToDelete(orgId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!orgToDelete || !accessToken) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await organizationDeleteCall(accessToken, orgToDelete);
|
||||
NotificationsManager.success("Organization deleted successfully");
|
||||
|
||||
setIsDeleteModalOpen(false);
|
||||
setOrgToDelete(null);
|
||||
await refetchOrganizations();
|
||||
} catch (error) {
|
||||
console.error("Error deleting organization:", error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setOrgToDelete(null);
|
||||
};
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
||||
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission
|
||||
if (
|
||||
(values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) ||
|
||||
(values.allowed_mcp_servers_and_groups &&
|
||||
(values.allowed_mcp_servers_and_groups.servers?.length > 0 ||
|
||||
values.allowed_mcp_servers_and_groups.accessGroups?.length > 0))
|
||||
) {
|
||||
values.object_permission = {};
|
||||
if (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) {
|
||||
values.object_permission.vector_stores = values.allowed_vector_store_ids;
|
||||
delete values.allowed_vector_store_ids;
|
||||
}
|
||||
if (values.allowed_mcp_servers_and_groups) {
|
||||
if (values.allowed_mcp_servers_and_groups.servers?.length > 0) {
|
||||
values.object_permission.mcp_servers = values.allowed_mcp_servers_and_groups.servers;
|
||||
}
|
||||
if (values.allowed_mcp_servers_and_groups.accessGroups?.length > 0) {
|
||||
values.object_permission.mcp_access_groups = values.allowed_mcp_servers_and_groups.accessGroups;
|
||||
}
|
||||
delete values.allowed_mcp_servers_and_groups;
|
||||
}
|
||||
}
|
||||
|
||||
await organizationCreateCall(accessToken, values);
|
||||
NotificationsManager.success("Organization created successfully");
|
||||
setIsOrgModalVisible(false);
|
||||
form.resetFields();
|
||||
await refetchOrganizations();
|
||||
} catch (error) {
|
||||
console.error("Error creating organization:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOrgModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<div>
|
||||
<Text>
|
||||
This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "}
|
||||
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer">
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<Button className="w-fit" onClick={() => setIsOrgModalVisible(true)}>
|
||||
+ Create New Organization
|
||||
</Button>
|
||||
)}
|
||||
{selectedOrgId ? (
|
||||
<OrganizationInfoView
|
||||
organizationId={selectedOrgId}
|
||||
onClose={() => {
|
||||
setSelectedOrgId(null);
|
||||
setEditOrg(false);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
is_org_admin={true} // You'll need to implement proper org admin check
|
||||
is_proxy_admin={userRole === "Admin"}
|
||||
userModels={userModels}
|
||||
editOrg={editOrg}
|
||||
/>
|
||||
) : (
|
||||
<TabGroup className="gap-2 h-[75vh] w-full">
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">
|
||||
<Tab>Your Organizations</Tab>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
|
||||
<Icon
|
||||
icon={RefreshIcon}
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="self-center"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Text>Click on “Organization ID” to view organization details.</Text>
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<Card className="w-full mx-auto flex-auto overflow-hidden overflow-y-auto max-h-[50vh]">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<OrganizationFilters
|
||||
filters={filters}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={setShowFilters}
|
||||
onChange={handleFilterChange}
|
||||
onReset={handleFilterReset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Organization ID</TableHeaderCell>
|
||||
<TableHeaderCell>Organization Name</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Spend (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>Budget (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>Models</TableHeaderCell>
|
||||
<TableHeaderCell>TPM / RPM Limits</TableHeaderCell>
|
||||
<TableHeaderCell>Info</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{organizations && organizations.length > 0
|
||||
? organizations
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((org: Organization) => (
|
||||
<TableRow key={org.organization_id}>
|
||||
<TableCell>
|
||||
<IdCell value={org.organization_id} onClick={setSelectedOrgId} />
|
||||
</TableCell>
|
||||
<TableCell>{org.organization_alias}</TableCell>
|
||||
<TableCell>
|
||||
<DateCell value={org.created_at} precision="date" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={org.spend} decimals={4} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell
|
||||
value={org.litellm_budget_table?.max_budget}
|
||||
decimals={2}
|
||||
emptyText="Unlimited"
|
||||
showZero
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "8-x",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className={org.models.length > 3 ? "px-0" : ""}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{Array.isArray(org.models) ? (
|
||||
<div className="flex flex-col">
|
||||
{org.models.length === 0 ? (
|
||||
<Badge size={"xs"} className="mb-1" color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start">
|
||||
{org.models.length > 3 && (
|
||||
<div>
|
||||
<Icon
|
||||
icon={
|
||||
expandedAccordions[org.organization_id || ""]
|
||||
? ChevronDownIcon
|
||||
: ChevronRightIcon
|
||||
}
|
||||
className="cursor-pointer"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setExpandedAccordions((prev) => ({
|
||||
...prev,
|
||||
[org.organization_id || ""]:
|
||||
!prev[org.organization_id || ""],
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{org.models.slice(0, 3).map((model, index) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{org.models.length > 3 &&
|
||||
!expandedAccordions[org.organization_id || ""] && (
|
||||
<Badge size={"xs"} color="gray" className="cursor-pointer">
|
||||
<Text>
|
||||
+{org.models.length - 3}{" "}
|
||||
{org.models.length - 3 === 1
|
||||
? "more model"
|
||||
: "more models"}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{expandedAccordions[org.organization_id || ""] && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{org.models.slice(3).map((model, index) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index + 3} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index + 3} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>
|
||||
TPM:{" "}
|
||||
{org.litellm_budget_table?.tpm_limit
|
||||
? org.litellm_budget_table?.tpm_limit
|
||||
: "Unlimited"}
|
||||
<br />
|
||||
RPM:{" "}
|
||||
{org.litellm_budget_table?.rpm_limit
|
||||
? org.litellm_budget_table?.rpm_limit
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{org.members?.length || 0} Members</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{userRole === "Admin" && (
|
||||
<>
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit organization"
|
||||
onClick={() => {
|
||||
setSelectedOrgId(org.organization_id);
|
||||
setEditOrg(true);
|
||||
}}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete organization"
|
||||
onClick={() => handleDelete(org.organization_id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Col>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
)}
|
||||
</Col>
|
||||
</Grid>
|
||||
<Modal title="Create Organization" visible={isOrgModalVisible} width={800} footer={null} onCancel={handleCancel}>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<Form.Item
|
||||
label="Organization Name"
|
||||
name="organization_alias"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input an organization name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Models" name="models">
|
||||
<ModelSelect
|
||||
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
|
||||
value={form.getFieldValue("models")}
|
||||
onChange={(values) => form.setFieldValue("models", values)}
|
||||
context="organization"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<NumericalInput step={0.01} precision={2} width={200} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Reset Budget" name="budget_duration">
|
||||
<Select2 defaultValue={null} placeholder="n/a">
|
||||
<Select2.Option value="24h">daily</Select2.Option>
|
||||
<Select2.Option value="7d">weekly</Select2.Option>
|
||||
<Select2.Option value="30d">monthly</Select2.Option>
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Vector Stores{" "}
|
||||
<Tooltip title="Select which vector stores this organization can access by default. Leave empty for access to all vector stores">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_vector_store_ids"
|
||||
className="mt-4"
|
||||
help="Select vector stores this organization can access. Leave empty for access to all vector stores"
|
||||
>
|
||||
<VectorStoreSelector
|
||||
onChange={(values) => form.setFieldValue("allowed_vector_store_ids", values)}
|
||||
value={form.getFieldValue("allowed_vector_store_ids")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select vector stores (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers and access groups this organization can access by default.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
className="mt-4"
|
||||
help="Select MCP servers and access groups this organization can access."
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(values) => form.setFieldValue("allowed_mcp_servers_and_groups", values)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers and access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button type="submit">Create Organization</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Organization?"
|
||||
message="Are you sure you want to delete this organization? This action cannot be undone."
|
||||
resourceInformationTitle="Organization Information"
|
||||
resourceInformation={[{ label: "Organization ID", value: orgToDelete, code: true }]}
|
||||
onCancel={cancelDelete}
|
||||
onOk={confirmDelete}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationsTable;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import OrganizationsTable from "./_components/organizations";
|
||||
import OrganizationsPanel from "./_components/OrganizationsPanel";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function OrganizationsPage() {
|
||||
const { accessToken, userRole, premiumUser } = useAuthorized();
|
||||
return <OrganizationsTable userRole={userRole ?? ""} accessToken={accessToken} premiumUser={premiumUser ?? false} />;
|
||||
return <OrganizationsPanel userRole={userRole ?? ""} accessToken={accessToken} premiumUser={premiumUser ?? false} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue