mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #34466 from BerriAI/litellm_/sleepy-pascal-0e7ee6
refactor(ui): migrate access-groups, vector-stores, organizations to shadcn
This commit is contained in:
commit
afa8fffd93
17 changed files with 805 additions and 701 deletions
|
|
@ -4,11 +4,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
|
@ -24,11 +19,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
|
@ -2035,11 +2025,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
|
|
@ -2051,18 +2036,10 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/vector-stores/_components/index.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2998,23 +2975,10 @@
|
|||
}
|
||||
},
|
||||
"src/components/common_components/Filters/FilterInput.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/Filters/FiltersButton.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/Filters/ResetFiltersButton.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/IconActionButton/BaseActionButton.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -3576,7 +3540,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/page_utils.test.ts": {
|
||||
|
|
|
|||
|
|
@ -1,68 +1,63 @@
|
|||
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Flex,
|
||||
Layout,
|
||||
List,
|
||||
Row,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
||||
interface AccessGroupDetailProps {
|
||||
accessGroupId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const MAX_PREVIEW = 5;
|
||||
|
||||
function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) {
|
||||
if (ids.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyMessage}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{ids.map((id) => (
|
||||
<Card key={id} size="sm">
|
||||
<CardContent>
|
||||
<code className="font-mono text-xs break-all text-foreground">{id}</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
|
||||
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
|
||||
const { token } = theme.useToken();
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [showAllKeys, setShowAllKeys] = useState(false);
|
||||
const [showAllTeams, setShowAllTeams] = useState(false);
|
||||
|
||||
const MAX_PREVIEW = 5;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Content
|
||||
style={{
|
||||
padding: token.paddingLG,
|
||||
paddingInline: token.paddingLG * 2,
|
||||
}}
|
||||
>
|
||||
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
</Content>
|
||||
<div className="p-6 px-12">
|
||||
<div className="flex min-h-[300px] items-center justify-center">
|
||||
<UiLoadingSpinner className="size-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessGroup) {
|
||||
return (
|
||||
<Content
|
||||
style={{
|
||||
padding: token.paddingLG,
|
||||
paddingInline: token.paddingLG * 2,
|
||||
}}
|
||||
>
|
||||
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
|
||||
<Empty description="Access group not found" />
|
||||
</Content>
|
||||
<div className="p-6 px-12">
|
||||
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack} className="mb-4">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Access group not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -75,224 +70,159 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
|
||||
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditModalVisible(true);
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "models",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{modelIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
modelIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={modelIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No models assigned to this group" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mcp",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
<Tag>{mcpServerIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
mcpServerIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={mcpServerIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No MCP servers assigned to this group" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "agents",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
<Tag>{agentIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
agentIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={agentIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No agents assigned to this group" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
|
||||
<div className="p-6 px-12">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack}>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
{accessGroup.access_group_name}
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
ID: <Text copyable>{accessGroup.access_group_id}</Text>
|
||||
</Text>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{accessGroup.access_group_name}</h1>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>ID: {accessGroup.access_group_id}</span>
|
||||
<CopyButton value={accessGroup.access_group_id} label="Copy access group ID" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditIcon size={16} />} onClick={handleEdit}>
|
||||
<Button onClick={() => setIsEditModalVisible(true)}>
|
||||
<EditIcon className="size-4" />
|
||||
Edit Access Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Group Details */}
|
||||
<Row style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Descriptions title="Group Details" column={1}>
|
||||
<Descriptions.Item label="Description">{accessGroup.description || "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Created">
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Group Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Description</dt>
|
||||
<dd className="text-foreground">{accessGroup.description || "—"}</dd>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(accessGroup.created_at).toLocaleString()}
|
||||
{accessGroup.created_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Updated">
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Last Updated</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(accessGroup.updated_at).toLocaleString()}
|
||||
{accessGroup.updated_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Row>
|
||||
|
||||
{/* Attached Keys & Teams */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<KeyIcon size={16} />
|
||||
Attached Keys
|
||||
<Tag>{keyIds?.length}</Tag>
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
keyIds?.length > MAX_PREVIEW ? (
|
||||
<Button type="link" onClick={() => setShowAllKeys(!showAllKeys)}>
|
||||
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{keyIds?.length > 0 ? (
|
||||
<Flex wrap="wrap" gap={8}>
|
||||
{displayedKeys.map((id) => (
|
||||
<Tag key={id}>
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty description="No keys attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<UsersIcon size={16} />
|
||||
Attached Teams
|
||||
<Tag>{teamIds?.length}</Tag>
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
teamIds?.length > MAX_PREVIEW ? (
|
||||
<Button type="link" onClick={() => setShowAllTeams(!showAllTeams)}>
|
||||
{showAllTeams ? "Show Less" : `View All (${teamIds?.length})`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{teamIds?.length > 0 ? (
|
||||
<Flex wrap="wrap" gap={8}>
|
||||
{displayedTeams.map((id) => (
|
||||
<Tag key={id}>
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{id}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty description="No teams attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Resources Tabs */}
|
||||
<Card>
|
||||
<Tabs defaultActiveKey="models" items={tabItems} />
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyIcon className="size-4" />
|
||||
Attached Keys
|
||||
<Badge variant="secondary">{keyIds.length}</Badge>
|
||||
</CardTitle>
|
||||
{keyIds.length > MAX_PREVIEW && (
|
||||
<CardAction>
|
||||
<Button variant="link" size="sm" onClick={() => setShowAllKeys(!showAllKeys)}>
|
||||
{showAllKeys ? "Show Less" : `View All (${keyIds.length})`}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{keyIds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayedKeys.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="font-mono">
|
||||
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No keys attached</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UsersIcon className="size-4" />
|
||||
Attached Teams
|
||||
<Badge variant="secondary">{teamIds.length}</Badge>
|
||||
</CardTitle>
|
||||
{teamIds.length > MAX_PREVIEW && (
|
||||
<CardAction>
|
||||
<Button variant="link" size="sm" onClick={() => setShowAllTeams(!showAllTeams)}>
|
||||
{showAllTeams ? "Show Less" : `View All (${teamIds.length})`}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{teamIds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayedTeams.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="font-mono">
|
||||
{id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No teams attached</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="models">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="models" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<LayersIcon className="size-4" />
|
||||
Models
|
||||
<Badge variant="secondary">{modelIds.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<ServerIcon className="size-4" />
|
||||
MCP Servers
|
||||
<Badge variant="secondary">{mcpServerIds.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<BotIcon className="size-4" />
|
||||
Agents
|
||||
<Badge variant="secondary">{agentIds.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="models" className="pt-4">
|
||||
<ResourceList ids={modelIds} emptyMessage="No models assigned to this group" />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" className="pt-4">
|
||||
<ResourceList ids={mcpServerIds} emptyMessage="No MCP servers assigned to this group" />
|
||||
</TabsContent>
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
<ResourceList ids={agentIds} emptyMessage="No agents assigned to this group" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<AccessGroupEditModal
|
||||
visible={isEditModalVisible}
|
||||
accessGroup={accessGroup}
|
||||
onCancel={() => setIsEditModalVisible(false)}
|
||||
/>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { Plus, SearchIcon, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
|
||||
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
|
||||
import { AccessGroupsTable } from "./AccessGroupsTable";
|
||||
|
|
@ -12,9 +13,6 @@ import { AccessGroup } from "./types";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
||||
function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
|
||||
return {
|
||||
id: r.access_group_id,
|
||||
|
|
@ -33,7 +31,6 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
|
|||
}
|
||||
|
||||
export function AccessGroupsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const { userRole } = useAuthorized();
|
||||
// Admin Viewer follows the read-parity rule: see access groups, no writes.
|
||||
const canModify = isProxyAdminRole(userRole ?? "");
|
||||
|
|
@ -62,31 +59,41 @@ export function AccessGroupsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Access Groups
|
||||
</Title>
|
||||
<Text type="secondary">Manage resource permissions for your organization</Text>
|
||||
</Space>
|
||||
{canModify && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateModalVisible(true)}>
|
||||
Create Access Group
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
prefix={<SearchIcon size={16} />}
|
||||
placeholder="Search groups by name, ID, or description..."
|
||||
style={{ maxWidth: 400 }}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
<div className="p-6 px-12">
|
||||
<div className="mb-4">
|
||||
<PageHeader
|
||||
title="Access Groups"
|
||||
subtitle="Manage resource permissions for your organization"
|
||||
actions={
|
||||
canModify ? (
|
||||
<Button onClick={() => setIsCreateModalVisible(true)}>
|
||||
<Plus className="size-4" />
|
||||
Create Access Group
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center">
|
||||
<InputGroup className="max-w-[400px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search groups by name, ID, or description..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
{searchText && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<AccessGroupsTable
|
||||
groups={filteredGroups}
|
||||
|
|
@ -120,6 +127,6 @@ export function AccessGroupsPage() {
|
|||
}}
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ describe("OrganizationFilters", () => {
|
|||
org_alias: "test org",
|
||||
};
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<OrganizationFilters
|
||||
filters={filtersWithActive}
|
||||
showFilters={false}
|
||||
|
|
@ -116,8 +116,7 @@ describe("OrganizationFilters", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
|
||||
const badgeWrapper = filtersButton.closest(".ant-badge");
|
||||
expect(badgeWrapper).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument();
|
||||
expect(container.querySelector("sup")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import TestVectorStoreTab from "./TestVectorStoreTab";
|
||||
import { VectorStore } from "@/components/vector_store_management/types";
|
||||
|
|
@ -60,31 +61,24 @@ describe("TestVectorStoreTab", () => {
|
|||
expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token");
|
||||
});
|
||||
|
||||
it("should update VectorStoreTester when selecting different vector store", () => {
|
||||
it("should update VectorStoreTester when selecting different vector store", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
|
||||
|
||||
// Find the select component
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Test Store 2"));
|
||||
|
||||
// Change selection
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for options to appear and click the second one
|
||||
const option2 = screen.getByText("Test Store 2");
|
||||
fireEvent.click(option2);
|
||||
|
||||
// Verify the tester component updated
|
||||
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456");
|
||||
});
|
||||
|
||||
it("should display vector store names in select options", () => {
|
||||
it("should display vector store names in select options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
|
||||
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
fireEvent.mouseDown(selectElement);
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
// Use getAllByText since the selected value also shows the name
|
||||
expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0);
|
||||
// The selected store's name may also render in the trigger, so only require at least one match.
|
||||
expect((await screen.findAllByText("Test Store 1")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Test Store 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
import React, { useState } from "react";
|
||||
import { Card, Select, Typography } from "antd";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { VectorStoreTester } from "./VectorStoreTester";
|
||||
import { VectorStore } from "@/components/vector_store_management/types";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface TestVectorStoreTabProps {
|
||||
accessToken: string | null;
|
||||
vectorStores: VectorStore[];
|
||||
}
|
||||
|
||||
const storeLabel = (store: VectorStore) => store.vector_store_name || store.vector_store_id;
|
||||
|
||||
const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
|
||||
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | undefined>(
|
||||
vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined,
|
||||
);
|
||||
const [selectedVectorStore, setSelectedVectorStore] = useState<VectorStore | null>(vectorStores[0] ?? null);
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<Card>
|
||||
<Text type="secondary">Access token is required to test vector stores.</Text>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Access token is required to test vector stores.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,9 +34,11 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
|
|||
if (vectorStores.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8">
|
||||
<Text type="secondary">No vector stores available. Create one first to test it.</Text>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No vector stores available. Create one first to test it.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,36 +46,41 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Title level={5}>Select Vector Store</Title>
|
||||
<Text type="secondary">Choose a vector store to test search queries against</Text>
|
||||
<h5 className="text-base font-medium text-foreground">Select Vector Store</h5>
|
||||
<p className="text-sm text-muted-foreground">Choose a vector store to test search queries against</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedVectorStoreId}
|
||||
onChange={setSelectedVectorStoreId}
|
||||
placeholder="Select a vector store"
|
||||
size="large"
|
||||
style={{ width: "100%" }}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
<Combobox
|
||||
items={vectorStores}
|
||||
value={selectedVectorStore}
|
||||
onValueChange={setSelectedVectorStore}
|
||||
itemToStringLabel={storeLabel}
|
||||
>
|
||||
{vectorStores.map((vs) => (
|
||||
<Select.Option key={vs.vector_store_id} value={vs.vector_store_id}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{vs.vector_store_name || vs.vector_store_id}</span>
|
||||
{vs.vector_store_name && (
|
||||
<span className="text-xs text-gray-500 font-mono">{vs.vector_store_id}</span>
|
||||
)}
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<ComboboxInput className="w-full" placeholder="Select a vector store" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching vector stores</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(store: VectorStore) => (
|
||||
<ComboboxItem key={store.vector_store_id} value={store}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{storeLabel(store)}</span>
|
||||
{store.vector_store_name && (
|
||||
<span className="font-mono text-xs text-muted-foreground">{store.vector_store_id}</span>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedVectorStoreId && <VectorStoreTester vectorStoreId={selectedVectorStoreId} accessToken={accessToken} />}
|
||||
{selectedVectorStore && (
|
||||
<VectorStoreTester vectorStoreId={selectedVectorStore.vector_store_id} accessToken={accessToken} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { vectorStoreSearchCall } from "@/components/networking";
|
||||
|
||||
import { VectorStoreTester } from "./VectorStoreTester";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
vectorStoreSearchCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWarning = vi.fn();
|
||||
vi.mock("@/components/molecules/message_manager", () => ({
|
||||
__esModule: true,
|
||||
default: { warning: (...args: unknown[]) => mockWarning(...args) },
|
||||
}));
|
||||
|
||||
const mockFromBackend = vi.fn();
|
||||
const mockSuccess = vi.fn();
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
fromBackend: (...args: unknown[]) => mockFromBackend(...args),
|
||||
success: (...args: unknown[]) => mockSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSearch = vi.mocked(vectorStoreSearchCall);
|
||||
|
||||
const searchResponse = {
|
||||
object: "vector_store.search_results.page",
|
||||
search_query: "hello",
|
||||
data: [
|
||||
{
|
||||
score: 0.91234,
|
||||
content: [{ text: "the quick brown fox", type: "text" }],
|
||||
file_id: "file-1",
|
||||
filename: "notes.txt",
|
||||
attributes: { source: "manual" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const EMPTY_STATE = "Test your vector store by entering a search query below";
|
||||
|
||||
const renderTester = () => render(<VectorStoreTester vectorStoreId="vs_123" accessToken="sk-test" />);
|
||||
|
||||
const queryInput = () => screen.getByPlaceholderText(/enter your search query/i);
|
||||
const searchButton = () => screen.getByRole("button", { name: /search/i });
|
||||
|
||||
describe("VectorStoreTester", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearch.mockResolvedValue(searchResponse);
|
||||
});
|
||||
|
||||
it("shows the empty state before any search has run", () => {
|
||||
renderTester();
|
||||
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /clear history/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not search until a non-blank query is entered", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTester();
|
||||
|
||||
await user.click(searchButton());
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
|
||||
await waitFor(() => expect(mockSearch).toHaveBeenCalledWith("sk-test", "vs_123", "hello"));
|
||||
});
|
||||
|
||||
it("renders the returned result and clears the query input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
|
||||
expect(await screen.findByText("Result 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Score: 0.9123")).toBeInTheDocument();
|
||||
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(queryInput()).toHaveValue(""));
|
||||
});
|
||||
|
||||
it("expands a result to reveal its content and metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
|
||||
expect(await screen.findByText("Result 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("the quick brown fox")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Result 1"));
|
||||
|
||||
expect(screen.getByText("the quick brown fox")).toBeInTheDocument();
|
||||
expect(screen.getByText("File ID:").parentElement).toHaveTextContent("file-1");
|
||||
expect(screen.getByText("Filename:").parentElement).toHaveTextContent("notes.txt");
|
||||
});
|
||||
|
||||
it("warns instead of searching when the query is only whitespace", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), " ");
|
||||
await user.type(queryInput(), "{Enter}");
|
||||
|
||||
expect(mockWarning).toHaveBeenCalledWith("Please enter a search query");
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits on Enter but not on Shift+Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.type(queryInput(), "{Shift>}{Enter}{/Shift}");
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(queryInput(), "{Enter}");
|
||||
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("reports a failed search and keeps the history empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSearch.mockRejectedValue(new Error("boom"));
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
|
||||
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith("Failed to search vector store"));
|
||||
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the search history", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
expect(await screen.findByText("Result 1")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /clear history/i }));
|
||||
|
||||
expect(screen.queryByText("Result 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button, Input, Card, Typography, Spin, Divider } from "antd";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { SendOutlined, DatabaseOutlined, LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight, Database, Send } from "lucide-react";
|
||||
import { vectorStoreSearchCall } from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text, Title } = Typography;
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
||||
interface VectorStoreContent {
|
||||
text: string;
|
||||
|
|
@ -98,18 +99,16 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full rounded-xl shadow-md">
|
||||
<div className="flex flex-col h-[600px]">
|
||||
<Card className={`w-full py-0 shadow-md ${className}`}>
|
||||
<div className="flex h-150 flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center">
|
||||
<DatabaseOutlined className="mr-2 text-blue-500" />
|
||||
<Title level={4} className="mb-0">
|
||||
Test Vector Store
|
||||
</Title>
|
||||
<Database className="mr-2 size-4 text-primary" />
|
||||
<h4 className="text-base font-medium text-foreground">Test Vector Store</h4>
|
||||
</div>
|
||||
{searchHistory.length > 0 && (
|
||||
<Button onClick={clearHistory} size="small">
|
||||
<Button variant="outline" size="sm" onClick={clearHistory}>
|
||||
Clear History
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -118,9 +117,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
{/* Results Area */}
|
||||
<div className="flex-1 overflow-auto p-4 pb-0">
|
||||
{searchHistory.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<DatabaseOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
|
||||
<Text>Test your vector store by entering a search query below</Text>
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Database className="mb-4 size-12" />
|
||||
<p className="text-sm">Test your vector store by entering a search query below</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -128,10 +127,10 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
<div key={index} className="space-y-2">
|
||||
{/* User Query */}
|
||||
<div className="text-right">
|
||||
<div className="inline-block max-w-[80%] rounded-lg shadow-xs p-3 bg-blue-50 border border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="inline-block max-w-[80%] rounded-lg bg-muted p-3 shadow-xs ring-1 ring-foreground/10">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<strong className="text-sm">Query</strong>
|
||||
<span className="text-xs text-gray-500">{formatTimestamp(entry.timestamp)}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatTimestamp(entry.timestamp)}</span>
|
||||
</div>
|
||||
<div className="text-left">{entry.query}</div>
|
||||
</div>
|
||||
|
|
@ -139,12 +138,12 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
|
||||
{/* Vector Store Response */}
|
||||
<div className="text-left">
|
||||
<div className="inline-block max-w-[80%] rounded-lg shadow-xs p-3 bg-white border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DatabaseOutlined className="text-green-500" />
|
||||
<div className="inline-block max-w-[80%] rounded-lg bg-card p-3 shadow-xs ring-1 ring-foreground/10">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Database className="size-4 text-primary" />
|
||||
<strong className="text-sm">Vector Store Results</strong>
|
||||
{entry.response && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600">
|
||||
<span className="rounded-sm bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{entry.response.data?.length || 0} results
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -156,40 +155,42 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
|
||||
|
||||
return (
|
||||
<div key={resultIndex} className="border rounded-lg overflow-hidden bg-gray-50">
|
||||
<div key={resultIndex} className="overflow-hidden rounded-lg border bg-muted/50">
|
||||
{/* Clickable Header */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
className="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-muted"
|
||||
onClick={() => toggleResultExpansion(index, resultIndex)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isExpanded ? (
|
||||
<DownOutlined className="text-gray-500 mr-2" />
|
||||
<ChevronDown className="mr-2 size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<RightOutlined className="text-gray-500 mr-2" />
|
||||
<ChevronRight className="mr-2 size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-sm">Result {resultIndex + 1}</span>
|
||||
<span className="text-sm font-medium">Result {resultIndex + 1}</span>
|
||||
{/* Show preview of content when collapsed */}
|
||||
{!isExpanded && result.content && result.content[0] && (
|
||||
<span className="ml-2 text-xs text-gray-500 truncate max-w-md">
|
||||
<span className="ml-2 max-w-md truncate text-xs text-muted-foreground">
|
||||
- {result.content[0].text.substring(0, 100)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
|
||||
<span className="rounded-sm bg-muted px-2 py-1 text-xs text-foreground">
|
||||
Score: {result.score.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-white p-3">
|
||||
<div className="border-t bg-card p-3">
|
||||
{/* Content */}
|
||||
{result.content &&
|
||||
result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex} className="mb-3">
|
||||
<div className="text-xs text-gray-500 mb-1">Content ({content.type})</div>
|
||||
<div className="text-sm bg-gray-50 p-3 rounded-sm border text-gray-800 max-h-40 overflow-y-auto">
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
Content ({content.type})
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto rounded-sm border bg-muted/50 p-3 text-sm text-foreground">
|
||||
{content.text}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -197,23 +198,23 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
|
||||
{/* Metadata */}
|
||||
{(result.file_id || result.filename || result.attributes) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500 mb-2 font-medium">Metadata</div>
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Metadata</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
{result.file_id && (
|
||||
<div className="bg-gray-50 p-2 rounded-sm">
|
||||
<div className="rounded-sm bg-muted/50 p-2">
|
||||
<span className="font-medium">File ID:</span> {result.file_id}
|
||||
</div>
|
||||
)}
|
||||
{result.filename && (
|
||||
<div className="bg-gray-50 p-2 rounded-sm">
|
||||
<div className="rounded-sm bg-muted/50 p-2">
|
||||
<span className="font-medium">Filename:</span> {result.filename}
|
||||
</div>
|
||||
)}
|
||||
{result.attributes && Object.keys(result.attributes).length > 0 && (
|
||||
<div className="bg-gray-50 p-2 rounded-sm">
|
||||
<span className="font-medium block mb-1">Attributes:</span>
|
||||
<pre className="text-xs bg-white p-2 rounded-sm border overflow-x-auto">
|
||||
<div className="rounded-sm bg-muted/50 p-2">
|
||||
<span className="mb-1 block font-medium">Attributes:</span>
|
||||
<pre className="overflow-x-auto rounded-sm border bg-card p-2 text-xs">
|
||||
{JSON.stringify(result.attributes, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -228,45 +229,40 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm">No results found</div>
|
||||
<div className="text-sm text-muted-foreground">No results found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < searchHistory.length - 1 && <Divider />}
|
||||
{index < searchHistory.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center my-4">
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
<div className="my-4 flex items-center justify-center">
|
||||
<UiLoadingSpinner className="size-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
<div className="border-t bg-card p-4">
|
||||
<div className="flex items-end space-x-2">
|
||||
<div className="flex-1">
|
||||
<TextArea
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter your search query... (Shift+Enter for new line)"
|
||||
disabled={isLoading}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ resize: "none" }}
|
||||
rows={1}
|
||||
className="field-sizing-fixed max-h-24 min-h-9 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading || !query.trim()}
|
||||
icon={<SendOutlined />}
|
||||
loading={isLoading}
|
||||
>
|
||||
<Button onClick={handleSearch} disabled={isLoading || !query.trim()}>
|
||||
{isLoading ? <UiLoadingSpinner className="size-4" /> : <Send className="size-4" />}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { vectorStoreListCall } from "@/components/networking";
|
||||
|
|
@ -25,18 +26,25 @@ vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null }
|
|||
|
||||
const mockVectorStoreListCall = vi.mocked(vectorStoreListCall);
|
||||
|
||||
const openManageTab = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" }));
|
||||
};
|
||||
|
||||
describe("VectorStoreManagement loading state", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VectorStoreManagement accessToken={null} userID={null} userRole={null} />);
|
||||
await openManageTab(user);
|
||||
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show the loading state until the vector store fetch settles", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveFetch: (value: { data: never[] }) => void = () => {};
|
||||
mockVectorStoreListCall.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
|
|
@ -44,6 +52,7 @@ describe("VectorStoreManagement loading state", () => {
|
|||
}),
|
||||
);
|
||||
render(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
|
||||
await openManageTab(user);
|
||||
expect(screen.getByText("table-loading")).toBeInTheDocument();
|
||||
|
||||
resolveFetch({ data: [] });
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Icon,
|
||||
Button as TremorButton,
|
||||
Col,
|
||||
Text,
|
||||
Grid,
|
||||
TabGroup,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
} from "@tremor/react";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import {
|
||||
vectorStoreListCall,
|
||||
vectorStoreDeleteCall,
|
||||
|
|
@ -27,6 +15,9 @@ import CreateVectorStore from "./CreateVectorStore";
|
|||
import TestVectorStoreTab from "./TestVectorStoreTab";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
|
||||
interface VectorStoreProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -45,6 +36,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | null>(null);
|
||||
const [editVectorStore, setEditVectorStore] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { onTabChange, hasVisited } = useVisitedTabs("create");
|
||||
|
||||
const fetchVectorStores = async () => {
|
||||
if (!accessToken) {
|
||||
|
|
@ -147,61 +139,56 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
<div className="mx-4 h-[75vh]">
|
||||
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<div className="flex justify-between mt-2 w-full items-center mb-4">
|
||||
<h1>Vector Store Management</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
|
||||
<Icon
|
||||
icon={RefreshIcon}
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="self-center cursor-pointer"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
{lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
|
||||
<Button variant="outline" size="icon-sm" aria-label="Refresh" onClick={handleRefreshClick}>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="mb-4">
|
||||
<p>You can use vector stores to store and retrieve LLM embeddings.</p>
|
||||
</Text>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
You can use vector stores to store and retrieve LLM embeddings.
|
||||
</p>
|
||||
|
||||
<TabGroup>
|
||||
<TabList className="mb-6">
|
||||
<Tab>Create Vector Store</Tab>
|
||||
<Tab>Manage Vector Stores</Tab>
|
||||
<Tab>Test Vector Store</Tab>
|
||||
</TabList>
|
||||
<Tabs defaultValue="create" onValueChange={onTabChange}>
|
||||
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="create" className="flex-none rounded-none px-4 py-2">
|
||||
Create Vector Store
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manage" className="flex-none rounded-none px-4 py-2">
|
||||
Manage Vector Stores
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" className="flex-none rounded-none px-4 py-2">
|
||||
Test Vector Store
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Tab 1: Create Vector Store */}
|
||||
<TabPanel>
|
||||
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
|
||||
</TabPanel>
|
||||
<TabsContent keepMounted={hasVisited("create")} value="create">
|
||||
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Manage Vector Stores */}
|
||||
<TabPanel>
|
||||
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Add Vector Store
|
||||
</TremorButton>
|
||||
<TabsContent keepMounted={hasVisited("manage")} value="manage">
|
||||
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Add Vector Store
|
||||
</Button>
|
||||
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<VectorStoreTable
|
||||
data={vectorStores}
|
||||
isLoading={isLoadingVectorStores}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<div className="grid grid-cols-1 gap-2 pt-2 pb-2 w-full mt-2">
|
||||
<VectorStoreTable
|
||||
data={vectorStores}
|
||||
isLoading={isLoadingVectorStores}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: Test Vector Store */}
|
||||
<TabPanel>
|
||||
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
<TabsContent keepMounted={hasVisited("test")} value="test">
|
||||
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create Vector Store Modal */}
|
||||
<VectorStoreForm
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { cx } from "@/lib/cva.config";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { Input } from "antd";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
|
|
@ -30,12 +30,13 @@ export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, on
|
|||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
prefix={Icon ? <Icon size={16} className="text-gray-500" /> : undefined}
|
||||
className={cx("w-64", className)}
|
||||
/>
|
||||
<InputGroup className={cx("w-64", className)}>
|
||||
{Icon && (
|
||||
<InputGroupAddon>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
<InputGroupInput placeholder={placeholder} value={localValue} onChange={handleChange} />
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,12 +21,18 @@ describe("FiltersButton", () => {
|
|||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show badge when hasActiveFilters is true", () => {
|
||||
it("should show the active-filter indicator when hasActiveFilters is true", () => {
|
||||
const onClick = vi.fn();
|
||||
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={true} />);
|
||||
const button = screen.getByRole("button", { name: /filters/i });
|
||||
const badgeWrapper = button.closest(".ant-badge");
|
||||
expect(badgeWrapper).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
|
||||
expect(container.querySelector("sup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the active-filter indicator when hasActiveFilters is false", () => {
|
||||
const onClick = vi.fn();
|
||||
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={false} />);
|
||||
expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
|
||||
expect(container.querySelector("sup")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render custom label when provided", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Badge, Button } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Filter } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
|
|
@ -16,10 +17,14 @@ export const FiltersButton: React.FC<FiltersButtonProps> = ({
|
|||
label = "Filters",
|
||||
}) => {
|
||||
return (
|
||||
<Badge color="blue" dot={hasActiveFilters}>
|
||||
<Button type="default" onClick={onClick} icon={<Filter size={16} />} className={active ? "bg-gray-100" : ""}>
|
||||
<span className="relative inline-flex">
|
||||
<Button variant="outline" onClick={onClick} className={cn(active && "bg-muted")}>
|
||||
<Filter className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
</Badge>
|
||||
{hasActiveFilters && (
|
||||
<sup aria-hidden="true" className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
|
|
@ -9,7 +9,8 @@ interface ResetFiltersButtonProps {
|
|||
|
||||
export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label = "Reset Filters" }) => {
|
||||
return (
|
||||
<Button type="default" onClick={onClick} icon={<RotateCcw size={16} />}>
|
||||
<Button variant="outline" onClick={onClick}>
|
||||
<RotateCcw className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ const mockUseTeams = vi.fn(() => mockUseTeamsData);
|
|||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useTeams: () => mockUseTeams(),
|
||||
useTeam: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
const mockOrg = {
|
||||
|
|
@ -204,3 +205,33 @@ test("should display team ID as fallback when alias is not found", async () => {
|
|||
expect(screen.getByText("team_999")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should keep unsaved settings edits when switching tabs and back", async () => {
|
||||
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
accessToken="test-token"
|
||||
is_org_admin={false}
|
||||
is_proxy_admin={true}
|
||||
userModels={[]}
|
||||
editOrg={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /Edit Settings/i }));
|
||||
|
||||
const alias = await screen.findByLabelText(/Organization Name/i);
|
||||
await user.clear(alias);
|
||||
await user.type(alias, "Renamed Org");
|
||||
expect(alias).toHaveValue("Renamed Org");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Overview" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(screen.getByLabelText(/Organization Name/i)).toHaveValue("Renamed Org");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { createTeamAliasMap } from "@/utils/teamUtils";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import { Badge, Card, Grid, Text, Title, Button as TremorButton } from "@tremor/react";
|
||||
import { Button, Tabs, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import MemberTable from "../common_components/MemberTable";
|
||||
import UserSearchModal from "../common_components/user_search_modal";
|
||||
|
|
@ -48,9 +51,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
|
||||
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
const canEditOrg = is_org_admin || is_proxy_admin;
|
||||
const { data: teams } = useTeams();
|
||||
const { onTabChange, hasVisited } = useVisitedTabs(editOrg ? "settings" : "overview");
|
||||
|
||||
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
|
||||
|
||||
|
|
@ -118,16 +121,6 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
return <div className="p-4">Organization not found</div>;
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string | null | undefined, key: string) => {
|
||||
const success = await utilCopyToClipboard(text);
|
||||
if (success) {
|
||||
setCopiedStates((prev) => ({ ...prev, [key]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedStates((prev) => ({ ...prev, [key]: false }));
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const orgExtraColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "Spend (USD)",
|
||||
|
|
@ -144,216 +137,213 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
render: (_: unknown, record: Member) => {
|
||||
const orgMember =
|
||||
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
|
||||
return (
|
||||
<Typography.Text>
|
||||
{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}
|
||||
</Typography.Text>
|
||||
);
|
||||
return <span>{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-screen w-full bg-background p-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<TremorButton icon={ArrowLeftIcon} onClick={onClose} variant="light" className="mb-4">
|
||||
<Button variant="ghost" onClick={onClose} className="mb-4">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Organizations
|
||||
</TremorButton>
|
||||
<Title>{orgData.organization_alias}</Title>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<Text className="text-gray-500 font-mono">{orgData.organization_id}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copiedStates["org-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
onClick={() => copyToClipboard(orgData.organization_id, "org-id")}
|
||||
className={`left-2 z-10 transition-all duration-200 ${
|
||||
copiedStates["org-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{orgData.organization_alias}</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-sm text-muted-foreground">{orgData.organization_id}</span>
|
||||
<CopyButton value={orgData.organization_id} label="Copy organization ID" iconClassName="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey={editOrg ? "settings" : "overview"}
|
||||
className="mb-4"
|
||||
items={[
|
||||
{
|
||||
key: "overview",
|
||||
label: "Overview",
|
||||
children: (
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||
<Card>
|
||||
<Text>Organization Details</Text>
|
||||
<div className="mt-2">
|
||||
<Text>Created: {new Date(orgData.created_at).toLocaleDateString()}</Text>
|
||||
<Text>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</Text>
|
||||
<Text>Created By: {orgData.created_by}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
<Tabs defaultValue={editOrg ? "settings" : "overview"} onValueChange={onTabChange} className="mb-4">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="members" className="flex-none rounded-none px-4 py-2">
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card>
|
||||
<Text>Budget Status</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(orgData.spend, 4)}</Title>
|
||||
<Text>
|
||||
of{" "}
|
||||
{orgData.litellm_budget_table.max_budget === null
|
||||
? "Unlimited"
|
||||
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
|
||||
</Text>
|
||||
{orgData.litellm_budget_table.budget_duration && (
|
||||
<Text className="text-gray-500">Reset: {orgData.litellm_budget_table.budget_duration}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<TabsContent keepMounted={hasVisited("overview")} value="overview" className="pt-4">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Organization Details</p>
|
||||
<div className="mt-2 text-sm text-foreground">
|
||||
<p>Created: {new Date(orgData.created_at).toLocaleDateString()}</p>
|
||||
<p>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</p>
|
||||
<p>Created By: {orgData.created_by}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Rate Limits</Text>
|
||||
<div className="mt-2">
|
||||
<Text>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</Text>
|
||||
<Text>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</Text>
|
||||
{orgData.litellm_budget_table.max_parallel_requests && (
|
||||
<Text>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Models</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.models.length === 0 ? (
|
||||
<Badge color="red">All proxy models</Badge>
|
||||
) : (
|
||||
orgData.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Teams</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.teams?.map((team, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="card"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
children: (
|
||||
<div className="space-y-4">
|
||||
<MemberTable
|
||||
members={(orgData.members || []).map((m) => ({
|
||||
role: m.user_role || "",
|
||||
user_id: m.user_id,
|
||||
user_email: m.user_email,
|
||||
}))}
|
||||
canEdit={canEditOrg}
|
||||
onEdit={(member) => {
|
||||
setSelectedEditMember(member);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={(member) => handleMemberDelete(member)}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Organization Role"
|
||||
extraColumns={orgExtraColumns}
|
||||
emptyText="No members found"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: "Settings",
|
||||
children: (
|
||||
<Card className="overflow-y-auto max-h-[65vh]">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Organization Settings</Title>
|
||||
{canEditOrg && !isEditing && (
|
||||
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Budget Status</p>
|
||||
<div className="mt-2 text-sm text-foreground">
|
||||
<p className="text-xl font-semibold">${formatNumberWithCommas(orgData.spend, 4)}</p>
|
||||
<p>
|
||||
of{" "}
|
||||
{orgData.litellm_budget_table.max_budget === null
|
||||
? "Unlimited"
|
||||
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
|
||||
</p>
|
||||
{orgData.litellm_budget_table.budget_duration && (
|
||||
<p className="text-muted-foreground">Reset: {orgData.litellm_budget_table.budget_duration}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isEditing ? (
|
||||
<OrgSettingsForm
|
||||
organizationId={organizationId}
|
||||
org={orgData}
|
||||
accessToken={accessToken || ""}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSaved={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Organization Name</Text>
|
||||
<div>{orgData.organization_alias}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Organization ID</Text>
|
||||
<div className="font-mono">{orgData.organization_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Created At</Text>
|
||||
<div>{new Date(orgData.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{orgData.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Rate Limits</Text>
|
||||
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
|
||||
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Budget</Text>
|
||||
<div>
|
||||
Max:{" "}
|
||||
{orgData.litellm_budget_table.max_budget !== null
|
||||
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
|
||||
: "No Limit"}
|
||||
</div>
|
||||
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Rate Limits</p>
|
||||
<div className="mt-2 text-sm text-foreground">
|
||||
<p>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</p>
|
||||
<p>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</p>
|
||||
{orgData.litellm_budget_table.max_parallel_requests && (
|
||||
<p>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Models</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.models.length === 0 ? (
|
||||
<Badge variant="secondary">All proxy models</Badge>
|
||||
) : (
|
||||
orgData.models.map((model, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Teams</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.teams?.map((team, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="card"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent keepMounted={hasVisited("members")} value="members" className="pt-4">
|
||||
<div className="space-y-4">
|
||||
<MemberTable
|
||||
members={(orgData.members || []).map((m) => ({
|
||||
role: m.user_role || "",
|
||||
user_id: m.user_id,
|
||||
user_email: m.user_email,
|
||||
}))}
|
||||
canEdit={canEditOrg}
|
||||
onEdit={(member) => {
|
||||
setSelectedEditMember(member);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={(member) => handleMemberDelete(member)}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Organization Role"
|
||||
extraColumns={orgExtraColumns}
|
||||
emptyText="No members found"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent keepMounted={hasVisited("settings")} value="settings" className="pt-4">
|
||||
<Card className="max-h-[65vh] overflow-y-auto">
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Organization Settings</h2>
|
||||
{canEditOrg && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Settings</Button>}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<OrgSettingsForm
|
||||
organizationId={organizationId}
|
||||
org={orgData}
|
||||
accessToken={accessToken || ""}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSaved={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Organization Name</p>
|
||||
<div>{orgData.organization_alias}</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Organization ID</p>
|
||||
<div className="font-mono">{orgData.organization_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Created At</p>
|
||||
<div>{new Date(orgData.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Models</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{orgData.models.map((model, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Rate Limits</p>
|
||||
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
|
||||
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Budget</p>
|
||||
<div>
|
||||
Max:{" "}
|
||||
{orgData.litellm_budget_table.max_budget !== null
|
||||
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
|
||||
: "No Limit"}
|
||||
</div>
|
||||
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
|
||||
</div>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="inline"
|
||||
className="border-t pt-4"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<UserSearchModal
|
||||
isVisible={isAddMemberModalVisible}
|
||||
onCancel={() => setIsAddMemberModalVisible(false)}
|
||||
|
|
|
|||
13
ui/litellm-dashboard/src/hooks/useVisitedTabs.ts
Normal file
13
ui/litellm-dashboard/src/hooks/useVisitedTabs.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useVisitedTabs(initialTab: string) {
|
||||
const [visited, setVisited] = useState<ReadonlySet<string>>(() => new Set([initialTab]));
|
||||
|
||||
const onTabChange = useCallback((value: unknown) => {
|
||||
setVisited((previous) => new Set(previous).add(String(value)));
|
||||
}, []);
|
||||
|
||||
const hasVisited = useCallback((value: string) => visited.has(value), [visited]);
|
||||
|
||||
return { onTabChange, hasVisited };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue