mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): migrate team subtree to shadcn
Migrate EditMembership, LoggingSettings, and TeamVirtualKeysTable off antd / Tremor to shadcn + react-hook-form per BLUEPRINT.md. - EditMembership: antd Form.useForm / Form.Item → react-hook-form useForm + Controller + shadcn Input/Label/Select. Multi-select is rendered as shadcn Select + Badge chip list following the AccessGroupBaseForm pattern. - LoggingSettings: antd Select (single + multi) → shadcn Select + Badge chip list. The multi-select 'Disabled Callbacks' control now adds selections one at a time and displays selected values as dismissible chips. The dynamic-params number input switches from NumericalInput (which coerces through Number) to a raw Input type=number so string values like '1.0' round-trip verbatim — fixing a pre-existing test failure where updateCallbackVar received '1' instead of '1.0'. - TeamVirtualKeysTable: swap Tremor Table / Button / Badge / Icon / Text and antd Popover / Tooltip / Skeleton for shadcn equivalents, mirroring the canonical VirtualKeysTable migration. Sorting, column resize, tooltips, and 'Last Active' info popover are preserved; layout classNames are unchanged. team/TeamInfo.tsx (1724 LoC) is deferred to BLOCKERS.md — its antd Form uses Form.List with Promise-based custom validators, Form.useWatch, shouldUpdate, hidden Form.Item registrations, and Select.OptGroup with tagRender, all of which exceed this batch's budget. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
5b15606577
commit
cf557f707c
4 changed files with 565 additions and 533 deletions
|
|
@ -31,3 +31,12 @@ stay on antd for phase 1 and will be addressed in a targeted follow-up.
|
|||
with Switch/InputNumber/Select controls. Defer until shared section
|
||||
unblocks.
|
||||
- `src/components/add_pass_through.tsx`: same dependency chain.
|
||||
- `src/components/team/TeamInfo.tsx` (1724 LoC): team detail/edit page with a
|
||||
deeply coupled antd `Form` — `Form.List` + inline Promise validators for
|
||||
per-model rate limits, `Form.useWatch` hooks driving dependent rendering,
|
||||
`shouldUpdate` patterns, hidden form fields registered via `Form.Item`,
|
||||
antd `Select.OptGroup` with a custom `tagRender` for guardrails, and the
|
||||
Tremor `Accordion` still required by the settings panel. Migration
|
||||
exceeds the two-attempt budget for this batch. Defer until Tremor
|
||||
`Accordion` has a shadcn replacement adopted and the antd Form surface
|
||||
can be broken into sub-forms.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { Form, Select } from "antd";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
|
||||
interface BaseMember {
|
||||
|
|
@ -16,6 +15,18 @@ interface BaseMember {
|
|||
role: string;
|
||||
}
|
||||
|
||||
interface AdditionalField {
|
||||
name: string;
|
||||
label: string | React.ReactNode;
|
||||
type: "input" | "select" | "numerical" | "multi-select";
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rules?: any[];
|
||||
step?: number;
|
||||
min?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface ModalConfig {
|
||||
title: string;
|
||||
roleOptions: Array<{
|
||||
|
|
@ -25,17 +36,7 @@ interface ModalConfig {
|
|||
defaultRole?: string;
|
||||
showEmail?: boolean;
|
||||
showUserId?: boolean;
|
||||
additionalFields?: Array<{
|
||||
name: string;
|
||||
label: string | React.ReactNode;
|
||||
type: "input" | "select" | "numerical" | "multi-select";
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rules?: any[];
|
||||
step?: number;
|
||||
min?: number;
|
||||
placeholder?: string;
|
||||
}>;
|
||||
additionalFields?: AdditionalField[];
|
||||
}
|
||||
|
||||
interface MemberModalProps<T extends BaseMember> {
|
||||
|
|
@ -47,6 +48,79 @@ interface MemberModalProps<T extends BaseMember> {
|
|||
config: ModalConfig;
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
user_email?: string;
|
||||
user_id?: string;
|
||||
role: string;
|
||||
max_budget_in_team?: number | string | null;
|
||||
tpm_limit?: number | string | null;
|
||||
rpm_limit?: number | string | null;
|
||||
allowed_models?: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
function MultiSelectField({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const selected = value ?? [];
|
||||
const remaining = options.filter((o) => !selected.includes(o.value));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(v) => {
|
||||
if (v) onChange([...selected, v]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder || "Select options"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remaining.length === 0 ? (
|
||||
<div className="py-2 px-3 text-sm text-muted-foreground">No more options</div>
|
||||
) : (
|
||||
remaining.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((v) => {
|
||||
const opt = options.find((o) => o.value === v);
|
||||
return (
|
||||
<Badge key={v} variant="secondary" className="flex items-center gap-1">
|
||||
{opt?.label ?? v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter((s) => s !== v))}
|
||||
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
|
||||
aria-label={`Remove ${opt?.label ?? v}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemberModal = <T extends BaseMember>({
|
||||
visible,
|
||||
onCancel,
|
||||
|
|
@ -55,47 +129,56 @@ const MemberModal = <T extends BaseMember>({
|
|||
mode,
|
||||
config,
|
||||
}: MemberModalProps<T>) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form and set initial values when modal becomes visible or initialData changes
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (mode === "edit" && initialData) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = initialData as any;
|
||||
const formValues = {
|
||||
...initialData,
|
||||
role: initialData.role || config.defaultRole,
|
||||
max_budget_in_team: d.max_budget_in_team || null,
|
||||
tpm_limit: d.tpm_limit || null,
|
||||
rpm_limit: d.rpm_limit || null,
|
||||
allowed_models: d.allowed_models || [],
|
||||
};
|
||||
form.setFieldsValue(formValues);
|
||||
} else {
|
||||
// For add mode, reset to defaults
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
role: config.defaultRole || config.roleOptions[0]?.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [visible, initialData, mode, form, config.defaultRole, config.roleOptions]);
|
||||
const defaultValues = useMemo<FormValues>(
|
||||
() => ({
|
||||
user_email: "",
|
||||
user_id: "",
|
||||
role: config.defaultRole || config.roleOptions[0]?.value || "",
|
||||
max_budget_in_team: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
allowed_models: [],
|
||||
}),
|
||||
[config.defaultRole, config.roleOptions],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSubmit = async (values: any) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (mode === "edit" && initialData) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = initialData as any;
|
||||
reset({
|
||||
...initialData,
|
||||
role: initialData.role || config.defaultRole || "",
|
||||
max_budget_in_team: d.max_budget_in_team ?? null,
|
||||
tpm_limit: d.tpm_limit ?? null,
|
||||
rpm_limit: d.rpm_limit ?? null,
|
||||
allowed_models: d.allowed_models ?? [],
|
||||
});
|
||||
} else {
|
||||
reset(defaultValues);
|
||||
}
|
||||
}, [visible, initialData, mode, reset, config.defaultRole, defaultValues]);
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const formData = Object.entries(values).reduce((acc, [key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
const trimmedValue = value.trim();
|
||||
if (
|
||||
trimmedValue === "" &&
|
||||
(key === "max_budget_in_team" ||
|
||||
key === "tpm_limit" ||
|
||||
key === "rpm_limit")
|
||||
) {
|
||||
if (trimmedValue === "" && (key === "max_budget_in_team" || key === "tpm_limit" || key === "rpm_limit")) {
|
||||
return { ...acc, [key]: null };
|
||||
}
|
||||
return { ...acc, [key]: trimmedValue };
|
||||
|
|
@ -104,59 +187,84 @@ const MemberModal = <T extends BaseMember>({
|
|||
}, {}) as T;
|
||||
|
||||
await Promise.resolve(onSubmit(formData));
|
||||
form.resetFields();
|
||||
reset(defaultValues);
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Helper function to get role label from value
|
||||
const getRoleLabel = (value: string) => {
|
||||
return config.roleOptions.find((option) => option.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const renderField = (field: {
|
||||
name: string;
|
||||
label: string | React.ReactNode;
|
||||
type: "input" | "select" | "numerical" | "multi-select";
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rules?: any[];
|
||||
step?: number;
|
||||
min?: number;
|
||||
placeholder?: string;
|
||||
}) => {
|
||||
const orderedRoleOptions = useMemo(() => {
|
||||
if (mode === "edit" && initialData) {
|
||||
return [
|
||||
...config.roleOptions.filter((o) => o.value === initialData.role),
|
||||
...config.roleOptions.filter((o) => o.value !== initialData.role),
|
||||
];
|
||||
}
|
||||
return config.roleOptions;
|
||||
}, [mode, initialData, config.roleOptions]);
|
||||
|
||||
const renderField = (field: AdditionalField) => {
|
||||
switch (field.type) {
|
||||
case "input":
|
||||
return <Input placeholder={field.placeholder} />;
|
||||
return <Input id={`field-${field.name}`} placeholder={field.placeholder} {...register(field.name)} />;
|
||||
case "numerical":
|
||||
return (
|
||||
<NumericalInput
|
||||
step={field.step || 1}
|
||||
min={field.min || 0}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={field.placeholder || "Enter a numerical value"}
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.name}
|
||||
render={({ field: rhfField }) => (
|
||||
<NumericalInput
|
||||
id={`field-${field.name}`}
|
||||
step={field.step || 1}
|
||||
min={field.min || 0}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={field.placeholder || "Enter a numerical value"}
|
||||
value={rhfField.value as number | string | null | undefined}
|
||||
onChange={(v: number | null) => rhfField.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select>
|
||||
{field.options?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.name}
|
||||
render={({ field: rhfField }) => (
|
||||
<Select value={(rhfField.value as string) || ""} onValueChange={(v) => rhfField.onChange(v)}>
|
||||
<SelectTrigger id={`field-${field.name}`}>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
case "multi-select":
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={field.placeholder || "Select options"}
|
||||
options={field.options}
|
||||
allowClear
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.name}
|
||||
render={({ field: rhfField }) => (
|
||||
<MultiSelectField
|
||||
value={(rhfField.value as string[]) || []}
|
||||
onChange={(v) => rhfField.onChange(v)}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
|
@ -168,28 +276,29 @@ const MemberModal = <T extends BaseMember>({
|
|||
<Dialog open={visible} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<DialogContent className="max-w-[1000px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{config.title || (mode === "add" ? "Add Member" : "Edit Member")}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{config.title || (mode === "add" ? "Add Member" : "Edit Member")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
{config.showEmail && (
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="user_email"
|
||||
className="mb-4"
|
||||
rules={[
|
||||
{ type: "email", message: "Please enter a valid email!" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="user@example.com" />
|
||||
</Form.Item>
|
||||
<div className="grid grid-cols-3 gap-4 items-start mb-4">
|
||||
<Label htmlFor="user_email" className="pt-2">
|
||||
Email
|
||||
</Label>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Input
|
||||
id="user_email"
|
||||
placeholder="user@example.com"
|
||||
{...register("user_email", {
|
||||
validate: (value) => {
|
||||
if (!value) return true;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value) || "Please enter a valid email!";
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.user_email && <p className="text-sm text-destructive">{errors.user_email.message as string}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.showEmail && config.showUserId && (
|
||||
|
|
@ -199,68 +308,60 @@ const MemberModal = <T extends BaseMember>({
|
|||
)}
|
||||
|
||||
{config.showUserId && (
|
||||
<Form.Item label="User ID" name="user_id" className="mb-4">
|
||||
<Input placeholder="user_123" />
|
||||
</Form.Item>
|
||||
<div className="grid grid-cols-3 gap-4 items-start mb-4">
|
||||
<Label htmlFor="user_id" className="pt-2">
|
||||
User ID
|
||||
</Label>
|
||||
<div className="col-span-2">
|
||||
<Input id="user_id" placeholder="user_123" {...register("user_id")} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<div className="grid grid-cols-3 gap-4 items-start mb-4">
|
||||
<Label htmlFor="role" className="pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Role</span>
|
||||
{mode === "edit" && initialData && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(Current: {getRoleLabel(initialData.role)})
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">(Current: {getRoleLabel(initialData.role)})</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
name="role"
|
||||
className="mb-4"
|
||||
rules={[{ required: true, message: "Please select a role!" }]}
|
||||
>
|
||||
<Select>
|
||||
{mode === "edit" && initialData
|
||||
? [
|
||||
...config.roleOptions.filter(
|
||||
(option) => option.value === initialData.role,
|
||||
),
|
||||
...config.roleOptions.filter(
|
||||
(option) => option.value !== initialData.role,
|
||||
),
|
||||
].map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))
|
||||
: config.roleOptions.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Label>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
rules={{ required: "Please select a role!" }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ""} onValueChange={(v) => field.onChange(v)}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedRoleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <p className="text-sm text-destructive">{errors.role.message as string}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.additionalFields?.map((field) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
className="mb-4"
|
||||
rules={field.rules}
|
||||
>
|
||||
{renderField(field)}
|
||||
</Form.Item>
|
||||
<div key={field.name} className="grid grid-cols-3 gap-4 items-start mb-4">
|
||||
<Label htmlFor={`field-${field.name}`} className="pt-2">
|
||||
{field.label}
|
||||
</Label>
|
||||
<div className="col-span-2">{renderField(field)}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-right mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="mr-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={onCancel} className="mr-2" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
|
|
@ -273,7 +374,7 @@ const MemberModal = <T extends BaseMember>({
|
|||
: "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,14 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Ban, Info, Plus, Settings, Trash2 } from "lucide-react";
|
||||
import {
|
||||
callbackInfo,
|
||||
callback_map,
|
||||
mapDisplayToInternalNames,
|
||||
} from "../callback_info_helpers";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
|
||||
const { Option } = Select;
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Ban, Info, Plus, Settings, Trash2, X } from "lucide-react";
|
||||
import { callbackInfo, callback_map, mapDisplayToInternalNames } from "../callback_info_helpers";
|
||||
|
||||
interface LoggingConfig {
|
||||
callback_name: string;
|
||||
|
|
@ -34,6 +23,33 @@ interface LoggingSettingsProps {
|
|||
onDisabledCallbacksChange?: (disabledCallbacks: string[]) => void;
|
||||
}
|
||||
|
||||
function CallbackLogo({ name, size = "sm" }: { name: string; size?: "sm" | "md" }) {
|
||||
const logo = callbackInfo[name]?.logo;
|
||||
const dims = size === "md" ? "w-5 h-5" : "w-4 h-4";
|
||||
if (!logo) {
|
||||
return (
|
||||
<div className={`${dims} rounded-full bg-muted flex items-center justify-center text-xs`}>{name.charAt(0)}</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={logo}
|
||||
alt={name}
|
||||
className={`${dims} object-contain`}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallbackDiv = document.createElement("div");
|
||||
fallbackDiv.className = `${dims} rounded-full bg-muted flex items-center justify-center text-xs`;
|
||||
fallbackDiv.textContent = name.charAt(0);
|
||||
parent.replaceChild(fallbackDiv, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
|
|
@ -53,11 +69,21 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
};
|
||||
|
||||
const handleDisabledCallbacksChange = (newDisabledCallbacks: string[]) => {
|
||||
// Map display names to internal callback values
|
||||
const mappedDisabledCallbacks = mapDisplayToInternalNames(newDisabledCallbacks);
|
||||
onDisabledCallbacksChange?.(mappedDisabledCallbacks);
|
||||
};
|
||||
|
||||
const addDisabledCallback = (name: string) => {
|
||||
if (!name || disabledCallbacks.includes(name)) return;
|
||||
handleDisabledCallbacksChange([...disabledCallbacks, name]);
|
||||
};
|
||||
|
||||
const removeDisabledCallback = (name: string) => {
|
||||
handleDisabledCallbacksChange(disabledCallbacks.filter((n) => n !== name));
|
||||
};
|
||||
|
||||
const remainingDisabledOptions = allCallbacks.filter((c) => !disabledCallbacks.includes(c));
|
||||
|
||||
const addLoggingConfig = () => {
|
||||
const newConfig: LoggingConfig = {
|
||||
callback_name: "",
|
||||
|
|
@ -80,7 +106,6 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
) => {
|
||||
const updatedConfigs = [...value];
|
||||
if (field === "callback_name") {
|
||||
// Convert display name to callback value and reset callback_vars when callback changes
|
||||
const callbackValue = callback_map[newValue] || newValue;
|
||||
updatedConfigs[index] = {
|
||||
...updatedConfigs[index],
|
||||
|
|
@ -111,8 +136,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
const renderDynamicParams = (config: LoggingConfig, configIndex: number) => {
|
||||
if (!config.callback_name) return null;
|
||||
|
||||
// Find the display name for the callback
|
||||
const callbackDisplayName = Object.entries(callback_map).find(([_, value]) => value === config.callback_name)?.[0];
|
||||
const callbackDisplayName = Object.entries(callback_map).find(([_, v]) => v === config.callback_name)?.[0];
|
||||
|
||||
if (!callbackDisplayName) return null;
|
||||
|
||||
|
|
@ -126,9 +150,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
<div className="w-3 h-3 bg-blue-100 dark:bg-blue-950/30 rounded-full flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Integration Parameters
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">Integration Parameters</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{Object.entries(dynamicParams).map(([paramName, paramType]) => (
|
||||
|
|
@ -141,8 +163,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Environment variable reference recommended:
|
||||
os.environ/{paramName.toUpperCase()}
|
||||
Environment variable reference recommended: os.environ/{paramName.toUpperCase()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -158,29 +179,23 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
)}
|
||||
</label>
|
||||
{paramType === "number" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Value must be between 0 and 1
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Value must be between 0 and 1</span>
|
||||
)}
|
||||
{paramType === "number" ? (
|
||||
<NumericalInput
|
||||
<Input
|
||||
type="number"
|
||||
step={0.01}
|
||||
width={400}
|
||||
placeholder={`os.environ/${paramName.toUpperCase()}`}
|
||||
value={config.callback_vars[paramName] || ""}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange={(e: any) =>
|
||||
updateCallbackVar(configIndex, paramName, e.target.value)
|
||||
}
|
||||
onChange={(e) => updateCallbackVar(configIndex, paramName, e.target.value)}
|
||||
onWheel={(event) => (event.currentTarget as HTMLInputElement).blur()}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={paramType === "password" ? "password" : "text"}
|
||||
placeholder={`os.environ/${paramName.toUpperCase()}`}
|
||||
value={config.callback_vars[paramName] || ""}
|
||||
onChange={(e) =>
|
||||
updateCallbackVar(configIndex, paramName, e.target.value)
|
||||
}
|
||||
onChange={(e) => updateCallbackVar(configIndex, paramName, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -196,70 +211,59 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Ban className="w-5 h-5 text-destructive" />
|
||||
<span className="text-base font-semibold text-foreground">
|
||||
Disabled Callbacks
|
||||
</span>
|
||||
<span className="text-base font-semibold text-foreground">Disabled Callbacks</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Select callbacks to disable for this key. Disabled callbacks
|
||||
will not receive any logging data.
|
||||
Select callbacks to disable for this key. Disabled callbacks will not receive any logging data.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Disabled Callbacks
|
||||
</label>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select callbacks to disable"
|
||||
value={disabledCallbacks}
|
||||
onChange={handleDisabledCallbacksChange}
|
||||
style={{ width: "100%" }}
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{allCallbacks.map((callbackName) => {
|
||||
const logo = callbackInfo[callbackName]?.logo;
|
||||
return (
|
||||
<Option
|
||||
key={callbackName}
|
||||
value={callbackName}
|
||||
label={callbackName}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={callbackName}
|
||||
className="w-4 h-4 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallbackDiv = document.createElement("div");
|
||||
fallbackDiv.className =
|
||||
"w-4 h-4 rounded-full bg-muted flex items-center justify-center text-xs";
|
||||
fallbackDiv.textContent = callbackName.charAt(0);
|
||||
parent.replaceChild(fallbackDiv, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{callbackName}</span>
|
||||
</div>
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
<label className="text-sm font-medium text-foreground">Disabled Callbacks</label>
|
||||
<Select value="" onValueChange={(v) => addDisabledCallback(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select callbacks to disable" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remainingDisabledOptions.length === 0 ? (
|
||||
<div className="py-2 px-3 text-sm text-muted-foreground">No more callbacks available</div>
|
||||
) : (
|
||||
remainingDisabledOptions.map((callbackName) => (
|
||||
<SelectItem key={callbackName} value={callbackName}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CallbackLogo name={callbackName} />
|
||||
<span>{callbackName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{disabledCallbacks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{disabledCallbacks.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="flex items-center gap-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDisabledCallback(name)}
|
||||
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select callbacks that should be disabled for this key. These
|
||||
callbacks will not receive any logging data.
|
||||
Select callbacks that should be disabled for this key. These callbacks will not receive any logging data.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -270,17 +274,13 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-base font-semibold text-foreground">
|
||||
Logging Integrations
|
||||
</span>
|
||||
<span className="text-base font-semibold text-foreground">Logging Integrations</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Configure callback logging integrations for this team.
|
||||
</TooltipContent>
|
||||
<TooltipContent>Configure callback logging integrations for this team.</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -299,9 +299,8 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
<div className="space-y-4">
|
||||
{value.map((config, index) => {
|
||||
const callbackDisplayName = config.callback_name
|
||||
? Object.entries(callback_map).find(([_, value]) => value === config.callback_name)?.[0]
|
||||
? Object.entries(callback_map).find(([_, v]) => v === config.callback_name)?.[0]
|
||||
: undefined;
|
||||
const logoUrl = callbackDisplayName ? callbackInfo[callbackDisplayName]?.logo : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
|
@ -310,16 +309,8 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={callbackDisplayName}
|
||||
className="w-5 h-5 object-contain"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{callbackDisplayName || "New Integration"} Configuration
|
||||
</span>
|
||||
{callbackDisplayName && <CallbackLogo name={callbackDisplayName} size="md" />}
|
||||
<span className="text-sm font-medium">{callbackDisplayName || "New Integration"} Configuration</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -335,85 +326,56 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Integration Type
|
||||
</label>
|
||||
<label className="text-sm font-medium text-foreground">Integration Type</label>
|
||||
<Select
|
||||
value={callbackDisplayName}
|
||||
placeholder="Select integration"
|
||||
onChange={(value) =>
|
||||
updateLoggingConfig(index, "callback_name", value)
|
||||
}
|
||||
className="w-full"
|
||||
optionLabelProp="label"
|
||||
value={callbackDisplayName || ""}
|
||||
onValueChange={(v) => updateLoggingConfig(index, "callback_name", v)}
|
||||
>
|
||||
{supportedCallbacks.map((callbackName) => {
|
||||
const logo = callbackInfo[callbackName]?.logo;
|
||||
return (
|
||||
<Option
|
||||
key={callbackName}
|
||||
value={callbackName}
|
||||
label={callbackName}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select integration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedCallbacks.map((callbackName) => (
|
||||
<SelectItem key={callbackName} value={callbackName}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={callbackName}
|
||||
className="w-4 h-4 object-contain"
|
||||
onError={(e) => {
|
||||
const target =
|
||||
e.target as HTMLImageElement;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallbackDiv =
|
||||
document.createElement("div");
|
||||
fallbackDiv.className =
|
||||
"w-4 h-4 rounded-full bg-muted flex items-center justify-center text-xs";
|
||||
fallbackDiv.textContent =
|
||||
callbackName.charAt(0);
|
||||
parent.replaceChild(fallbackDiv, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CallbackLogo name={callbackName} />
|
||||
<span>{callbackName}</span>
|
||||
</div>
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Event Type
|
||||
</label>
|
||||
<label className="text-sm font-medium text-foreground">Event Type</label>
|
||||
<Select
|
||||
value={config.callback_type}
|
||||
onChange={(value) =>
|
||||
updateLoggingConfig(index, "callback_type", value)
|
||||
}
|
||||
className="w-full"
|
||||
onValueChange={(v) => updateLoggingConfig(index, "callback_type", v)}
|
||||
>
|
||||
<Option value="success">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
|
||||
<span>Success Only</span>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="failure">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span>Failure Only</span>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="success_and_failure">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span>Success & Failure</span>
|
||||
</div>
|
||||
</Option>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="success">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
|
||||
<span>Success Only</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="failure">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span>Failure Only</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="success_and_failure">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span>Success & Failure</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -428,9 +390,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
|
|||
{value.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground border-2 border-dashed border-border rounded-lg bg-muted/50">
|
||||
<Settings className="w-12 h-12 text-muted-foreground/60 mb-3 mx-auto" />
|
||||
<div className="text-base font-medium mb-1">
|
||||
No logging integrations configured
|
||||
</div>
|
||||
<div className="text-base font-medium mb-1">No logging integrations configured</div>
|
||||
<div className="text-sm text-muted-foreground/80">
|
||||
Click "Add Integration" to configure logging for this team
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@
|
|||
|
||||
"use client";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ChevronDown as ChevronDownIcon, ChevronRight as ChevronRightIcon, ChevronUp as ChevronUpIcon, ChevronsUpDown as SwitchVerticalIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsUpDown, Info } from "lucide-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
|
|
@ -12,30 +19,14 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Info as InfoCircleOutlined } from "lucide-react";
|
||||
import { Popover, Skeleton, Tooltip } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import FilterComponent, { FilterOption } from "../molecules/filter";
|
||||
import { Organization } from "../networking";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
interface TeamVirtualKeysTableProps {
|
||||
teamId: string;
|
||||
|
|
@ -43,6 +34,25 @@ interface TeamVirtualKeysTableProps {
|
|||
organization: Organization | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell that renders a truncated value with a tooltip showing the full value.
|
||||
*/
|
||||
function TruncatedCell({ value, width }: { value: string | null | undefined; width: number }) {
|
||||
const displayValue = value ?? "-";
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{value ? <TooltipContent>{displayValue}</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TeamVirtualKeysTable – variant of VirtualKeysTable scoped to a single team.
|
||||
* Displays all virtual keys belonging to the team with same format and styling.
|
||||
|
|
@ -50,9 +60,7 @@ interface TeamVirtualKeysTableProps {
|
|||
export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "created_at", desc: true },
|
||||
]);
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
const [tablePagination, setTablePagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
|
|
@ -173,9 +181,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
const { organizationIds } = teamFilterOptions;
|
||||
if (!organizationIds.length) return [];
|
||||
const lower = searchText.toLowerCase();
|
||||
const filtered = lower
|
||||
? organizationIds.filter((id) => id.toLowerCase().includes(lower))
|
||||
: organizationIds;
|
||||
const filtered = lower ? organizationIds.filter((id) => id.toLowerCase().includes(lower)) : organizationIds;
|
||||
return filtered.map((id) => ({ label: id, value: id }));
|
||||
},
|
||||
},
|
||||
|
|
@ -186,9 +192,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
searchFn: async (searchText: string) => {
|
||||
const { keyAliases } = teamFilterOptions;
|
||||
const lower = searchText.toLowerCase();
|
||||
const filtered = lower
|
||||
? keyAliases.filter((alias) => alias.toLowerCase().includes(lower))
|
||||
: keyAliases;
|
||||
const filtered = lower ? keyAliases.filter((alias) => alias.toLowerCase().includes(lower)) : keyAliases;
|
||||
return filtered.map((alias) => ({ label: alias, value: alias }));
|
||||
},
|
||||
},
|
||||
|
|
@ -200,10 +204,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
const { userIds } = teamFilterOptions;
|
||||
const lower = searchText.toLowerCase();
|
||||
const filtered = lower
|
||||
? userIds.filter(
|
||||
(u) =>
|
||||
u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower),
|
||||
)
|
||||
? userIds.filter((u) => u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower))
|
||||
: userIds;
|
||||
return filtered.map((u) => ({
|
||||
label: u.email ? `${u.id} (${u.email})` : u.id,
|
||||
|
|
@ -227,17 +228,21 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
const value = info.getValue() as string;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
onClick={() => setSelectedKey(info.row.original)}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-primary bg-primary/10 hover:bg-primary/20 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block rounded"
|
||||
style={{ maxWidth: width }}
|
||||
onClick={() => setSelectedKey(info.row.original)}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{value ? <TooltipContent>{value}</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -247,20 +252,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Key Alias",
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span
|
||||
className="font-mono text-xs truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <TruncatedCell value={info.getValue() as string} width={info.cell.column.getSize()} />,
|
||||
},
|
||||
{
|
||||
id: "key_name",
|
||||
|
|
@ -286,18 +278,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const user = info.getValue() as { user_email?: string } | undefined;
|
||||
const value = user?.user_email;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span
|
||||
className="font-mono text-xs truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
return <TruncatedCell value={user?.user_email} width={info.cell.column.getSize()} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -309,17 +290,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={displayValue}>
|
||||
<span
|
||||
className="font-mono text-xs truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
>
|
||||
{displayValue ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
return <TruncatedCell value={displayValue} width={info.cell.column.getSize()} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -342,17 +313,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
cell: (info) => {
|
||||
const value = info.getValue() as string | null;
|
||||
const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={displayValue}>
|
||||
<span
|
||||
className="font-mono text-xs truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
>
|
||||
{displayValue ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
return <TruncatedCell value={displayValue} width={info.cell.column.getSize()} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -372,11 +333,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
Last Active
|
||||
<Popover
|
||||
content="This is a new field and is not backfilled. Only new key usage will update this value."
|
||||
trigger="hover"
|
||||
>
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="inline-flex items-center" aria-label="Last Active info">
|
||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="text-xs max-w-xs">
|
||||
This is a new field and is not backfilled. Only new key usage will update this value.
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
),
|
||||
|
|
@ -387,9 +352,19 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
if (!value) return "Unknown";
|
||||
const date = new Date(value as string);
|
||||
return (
|
||||
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
|
||||
<span>{date.toLocaleDateString()}</span>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{date.toLocaleDateString()}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "long",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -443,80 +418,74 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const models = info.getValue() as string[];
|
||||
if (!Array.isArray(models)) return null;
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<Badge variant="destructive" className="mb-1">
|
||||
All Proxy Models
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col py-2">
|
||||
{Array.isArray(models) ? (
|
||||
<div className="flex flex-col">
|
||||
{models.length === 0 ? (
|
||||
<Badge size="xs" className="mb-1" color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
<div className="flex items-start">
|
||||
{models.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer mr-1"
|
||||
onClick={() =>
|
||||
setExpandedAccordions((prev) => ({
|
||||
...prev,
|
||||
[info.row.id]: !prev[info.row.id],
|
||||
}))
|
||||
}
|
||||
aria-label={expandedAccordions[info.row.id] ? "Collapse" : "Expand"}
|
||||
>
|
||||
{expandedAccordions[info.row.id] ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.slice(0, 3).map((model, index) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index} variant="destructive">
|
||||
All Proxy Models
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index} variant="secondary">
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{models.length > 3 && !expandedAccordions[info.row.id] && (
|
||||
<Badge variant="outline" className="cursor-pointer">
|
||||
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start">
|
||||
{models.length > 3 && (
|
||||
<div>
|
||||
<Icon
|
||||
icon={expandedAccordions[info.row.id] ? ChevronDownIcon : ChevronRightIcon}
|
||||
className="cursor-pointer"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
setExpandedAccordions((prev) => ({
|
||||
...prev,
|
||||
[info.row.id]: !prev[info.row.id],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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>
|
||||
),
|
||||
)}
|
||||
{models.length > 3 && !expandedAccordions[info.row.id] && (
|
||||
<Badge size="xs" color="gray" className="cursor-pointer">
|
||||
<Text>
|
||||
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{expandedAccordions[info.row.id] && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{expandedAccordions[info.row.id] && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.slice(3).map((model, index) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index + 3} variant="destructive">
|
||||
All Proxy Models
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index + 3} variant="secondary">
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -542,8 +511,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
|
||||
const handleSortingChange = useCallback(
|
||||
(updaterOrValue: React.SetStateAction<SortingState>) => {
|
||||
const newSorting =
|
||||
typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
||||
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
||||
setSorting(newSorting);
|
||||
if (newSorting?.length > 0) {
|
||||
const sortState = newSorting[0];
|
||||
|
|
@ -599,35 +567,37 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
<div className="flex items-center justify-end w-full mb-4">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{isLoading || isFetching ? (
|
||||
<Skeleton.Node active style={{ width: 74, height: 20 }} />
|
||||
<Skeleton className="h-5 w-[74px]" />
|
||||
) : (
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isLoading || isFetching ? (
|
||||
<Skeleton.Button active size="small" style={{ width: 84, height: 30 }} />
|
||||
<Skeleton className="h-8 w-[84px]" />
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isLoading || isFetching ? (
|
||||
<Skeleton.Button active size="small" style={{ width: 58, height: 30 }} />
|
||||
<Skeleton className="h-8 w-[58px]" />
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={isLoading || isFetching || !table.getCanNextPage()}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -635,16 +605,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
<TableHead
|
||||
key={header.id}
|
||||
data-header-id={header.id}
|
||||
className={`py-1 h-8 relative hover:bg-gray-50 ${
|
||||
className={`py-1 h-8 relative hover:bg-muted ${
|
||||
header.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
? "sticky right-0 bg-background shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
|
|
@ -653,23 +623,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
cursor: header.column.getCanSort() ? "pointer" : "default",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const resizer = document.querySelector(
|
||||
`[data-header-id="${header.id}"] .resizer`,
|
||||
);
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer) (resizer as HTMLElement).style.opacity = "0.5";
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const resizer = document.querySelector(
|
||||
`[data-header-id="${header.id}"] .resizer`,
|
||||
);
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer && !header.column.getIsResizing())
|
||||
(resizer as HTMLElement).style.opacity = "0";
|
||||
}}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
|
|
@ -681,11 +643,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
asc: <ChevronUp className="h-4 w-4 text-primary" />,
|
||||
desc: <ChevronDown className="h-4 w-4 text-primary" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -702,7 +664,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
top: 0,
|
||||
height: "100%",
|
||||
width: "5px",
|
||||
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
|
||||
background: header.column.getIsResizing() ? "hsl(var(--primary))" : "transparent",
|
||||
cursor: "col-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
|
|
@ -710,16 +672,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading || isFetching ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Loading keys...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -752,7 +714,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>No keys found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue