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:
cursor 2026-04-24 06:44:40 +00:00
parent 5b15606577
commit cf557f707c
No known key found for this signature in database
4 changed files with 565 additions and 533 deletions

View file

@ -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.

View file

@ -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>
);

View file

@ -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>

View file

@ -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>