mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
refactor(ui): migrate guardrails table onto shared DataTable (#33303)
* feat(ui): migrate guardrails table onto shared DataTable Move the guardrails list onto the shared DataTable + cell library as the proof-of-concept for the simple-tables design migration, following the Teams reference pattern. Split the table into a thin container (guardrail_table.tsx) and column defs (guardrailTableColumns.tsx): client-side sort defaulting to created_at desc, a search + refresh toolbar, IdCell / DateCell / StatusBadge cells, real provider logos, a rich empty state, and skeleton loading rows. Row actions move into a per-row overflow menu; deletion stays disabled for config-file guardrails, now surfaced as a disabled menu item instead of a greyed trash icon. Detail view and the delete modal remain owned by GuardrailsPanel. Restyle the "Add New Guardrail" control to the shared Button + dropdown menu. Update the regression tests for the menu-based actions and drop the now-stale eslint suppression entry that the rewrite eliminated. * fix(ui): match guardrails table to the design Address design-review feedback on the guardrails migration: - Drop the search + refresh toolbar. The original table had neither and the SimpleTable design has no toolbar; the container now just renders the sorted table and its empty state. - Give the Guardrail ID cell the design's hover affordance by rendering it with the shared IdentityCell (monospace, chevron on hover) instead of the blue IdCell pill. - Stop pinning the actions column. Pinning added a sticky divider that the design and the Teams table don't have; it is now a plain right-aligned menu column, matching Teams. * fix(ui): match loading skeleton row height to loaded rows The compact skeleton row did not carry the h-8 height that real compact rows get, so loading rows rendered shorter than loaded ones and the table height jumped when data arrived. Mirror the same size-based height on the skeleton row in the shared DataTable so every compact table loads at a stable height * test(ui): drop stale onGuardrailUpdated from guardrails table baseProps The prop was removed from GuardrailTableProps when the toolbar went away; the test baseProps still listed it. Harmless at the call site since it is spread rather than an object literal, but dead and worth removing * fix(ui): remove dead edit_guardrail_form after guardrails migration The guardrails table migration dropped the last import of EditGuardrailForm, which knip flags as an unused file. The form was already unreachable before the migration: the table wired a delete button only, and nothing ever called handleEditClick to open the modal, so the import was the sole thing keeping the file referenced. Delete it and prune its now-stale eslint suppression entry. Guardrail editing is unchanged and lives in the detail view (GuardrailInfoView)
This commit is contained in:
parent
32af83d63a
commit
f8c49f51cc
9 changed files with 324 additions and 854 deletions
|
|
@ -301,17 +301,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": {
|
||||
"max-params": {
|
||||
"count": 1
|
||||
|
|
@ -339,14 +328,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/guardrail_table.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -105,6 +105,6 @@ describe("GuardrailsPanel", () => {
|
|||
expect(screen.getByText("Guardrails")).toBeInTheDocument();
|
||||
// Activate the Guardrails tab so its content (including the Add button) is rendered
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
expect(screen.getByText("+ Add New Guardrail")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add New Guardrail")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button, Dropdown, Tabs } from "antd";
|
||||
import { DownOutlined, PlusOutlined, CodeOutlined } from "@ant-design/icons";
|
||||
import { Tabs } from "antd";
|
||||
import { ChevronDown, Code, Plus } from "lucide-react";
|
||||
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import AddGuardrailForm from "./add_guardrail_form";
|
||||
import GuardrailTable from "./guardrail_table";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
|
|
@ -133,30 +141,26 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
children: (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "provider",
|
||||
icon: <PlusOutlined />,
|
||||
label: "Add Provider Guardrail",
|
||||
onClick: handleAddGuardrail,
|
||||
},
|
||||
{
|
||||
key: "custom_code",
|
||||
icon: <CodeOutlined />,
|
||||
label: "Create Custom Code Guardrail",
|
||||
onClick: handleAddCustomCodeGuardrail,
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
disabled={!accessToken}
|
||||
>
|
||||
<Button disabled={!accessToken}>
|
||||
+ Add New Guardrail <DownOutlined className="ml-2" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={!accessToken}
|
||||
className={cn(buttonVariants({ variant: "default" }))}
|
||||
>
|
||||
<Plus />
|
||||
Add New Guardrail
|
||||
<ChevronDown />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onClick={handleAddGuardrail}>
|
||||
<Plus />
|
||||
Add Provider Guardrail
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAddCustomCodeGuardrail}>
|
||||
<Code />
|
||||
Create Custom Code Guardrail
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{selectedGuardrailId ? (
|
||||
|
|
@ -171,9 +175,6 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
guardrailsList={guardrailsList}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
accessToken={accessToken}
|
||||
onGuardrailUpdated={fetchGuardrails}
|
||||
isAdmin={isAdmin}
|
||||
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,491 +0,0 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Typography, Select, Input, Switch, Modal } from "antd";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import {
|
||||
guardrail_provider_map,
|
||||
guardrailLogoMap,
|
||||
getGuardrailProviders,
|
||||
getSupportedModesForProvider,
|
||||
toModeArray,
|
||||
type SkipSystemMessageChoice,
|
||||
type SkipToolMessageChoice,
|
||||
} from "./guardrail_info_helpers";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import PiiConfiguration from "./pii_configuration";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface EditGuardrailFormProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
accessToken: string | null;
|
||||
onSuccess: () => void;
|
||||
guardrailId: string;
|
||||
/** Full stored params merged into PUT so optional fields (e.g. content filter) are preserved. */
|
||||
fullLitellmParams?: Record<string, unknown> | null;
|
||||
initialValues: {
|
||||
guardrail_name: string;
|
||||
provider: string;
|
||||
mode: string;
|
||||
default_on: boolean;
|
||||
pii_entities_config?: { [key: string]: string };
|
||||
skip_system_message_choice?: SkipSystemMessageChoice;
|
||||
skip_tool_message_choice?: SkipToolMessageChoice;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface GuardrailSettings {
|
||||
supported_entities: string[];
|
||||
supported_actions: string[];
|
||||
supported_modes: string[];
|
||||
supported_modes_by_provider?: Record<string, string[]>;
|
||||
pii_entity_categories: Array<{
|
||||
category: string;
|
||||
entities: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const EditGuardrailForm: React.FC<EditGuardrailFormProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
accessToken,
|
||||
onSuccess,
|
||||
guardrailId,
|
||||
fullLitellmParams,
|
||||
initialValues,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(initialValues?.provider || null);
|
||||
const [guardrailSettings, setGuardrailSettings] = useState<GuardrailSettings | null>(null);
|
||||
const [selectedEntities, setSelectedEntities] = useState<string[]>([]);
|
||||
const [selectedActions, setSelectedActions] = useState<{ [key: string]: string }>({});
|
||||
|
||||
// Fetch guardrail settings when the component mounts
|
||||
useEffect(() => {
|
||||
const fetchGuardrailSettings = async () => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
||||
const data = await getGuardrailUISettings(accessToken);
|
||||
setGuardrailSettings(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching guardrail settings:", error);
|
||||
NotificationsManager.fromBackend("Failed to load guardrail settings");
|
||||
}
|
||||
};
|
||||
|
||||
fetchGuardrailSettings();
|
||||
}, [accessToken]);
|
||||
|
||||
// Initialize selected entities and actions from initialValues
|
||||
useEffect(() => {
|
||||
if (initialValues?.pii_entities_config && Object.keys(initialValues.pii_entities_config).length > 0) {
|
||||
const entities = Object.keys(initialValues.pii_entities_config);
|
||||
setSelectedEntities(entities);
|
||||
setSelectedActions(initialValues.pii_entities_config);
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
const handleProviderChange = (value: string) => {
|
||||
setSelectedProvider(value);
|
||||
// Reset form fields that are provider-specific
|
||||
form.setFieldsValue({
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
// Reset PII selections when changing provider
|
||||
setSelectedEntities([]);
|
||||
setSelectedActions({});
|
||||
};
|
||||
|
||||
const handleEntitySelect = (entity: string) => {
|
||||
setSelectedEntities((prev) => {
|
||||
if (prev.includes(entity)) {
|
||||
return prev.filter((e) => e !== entity);
|
||||
} else {
|
||||
return [...prev, entity];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionSelect = (entity: string, action: string) => {
|
||||
setSelectedActions((prev) => ({
|
||||
...prev,
|
||||
[entity]: action,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Get the guardrail provider value from the map
|
||||
const guardrailProvider = guardrail_provider_map[values.provider];
|
||||
|
||||
const litellm_params: Record<string, unknown> =
|
||||
fullLitellmParams && typeof fullLitellmParams === "object" ? { ...fullLitellmParams } : {};
|
||||
|
||||
litellm_params.guardrail = guardrailProvider;
|
||||
litellm_params.mode = values.mode;
|
||||
litellm_params.default_on = values.default_on;
|
||||
|
||||
const skipChoice = values.skip_system_message_choice as SkipSystemMessageChoice | undefined;
|
||||
if (skipChoice === "yes") {
|
||||
litellm_params.skip_system_message_in_guardrail = true;
|
||||
} else if (skipChoice === "no") {
|
||||
litellm_params.skip_system_message_in_guardrail = false;
|
||||
} else {
|
||||
delete litellm_params.skip_system_message_in_guardrail;
|
||||
}
|
||||
|
||||
const skipToolChoice = values.skip_tool_message_choice as SkipToolMessageChoice | undefined;
|
||||
if (skipToolChoice === "yes") {
|
||||
litellm_params.skip_tool_message_in_guardrail = true;
|
||||
} else if (skipToolChoice === "no") {
|
||||
litellm_params.skip_tool_message_in_guardrail = false;
|
||||
} else {
|
||||
delete litellm_params.skip_tool_message_in_guardrail;
|
||||
}
|
||||
|
||||
let guardrail_info: Record<string, unknown> = {};
|
||||
|
||||
// For Presidio PII, add the entity and action configurations
|
||||
if (values.provider === "PresidioPII" && selectedEntities.length > 0) {
|
||||
const piiEntitiesConfig: { [key: string]: string } = {};
|
||||
selectedEntities.forEach((entity) => {
|
||||
piiEntitiesConfig[entity] = selectedActions[entity] || "MASK"; // Default to MASK if no action selected
|
||||
});
|
||||
|
||||
litellm_params.pii_entities_config = piiEntitiesConfig;
|
||||
}
|
||||
// Add config values to the guardrail_info if provided
|
||||
else if (values.config) {
|
||||
try {
|
||||
const configObj = JSON.parse(values.config);
|
||||
// For some guardrails, the config values need to be in litellm_params
|
||||
// Especially for providers like Bedrock that need guardrailIdentifier and guardrailVersion
|
||||
if (values.provider === "Bedrock" && configObj) {
|
||||
if (configObj.guardrail_id) {
|
||||
litellm_params.guardrailIdentifier = configObj.guardrail_id;
|
||||
}
|
||||
if (configObj.guardrail_version) {
|
||||
litellm_params.guardrailVersion = configObj.guardrail_version;
|
||||
}
|
||||
} else {
|
||||
// For other providers, add the config to guardrail_info
|
||||
guardrail_info = configObj;
|
||||
}
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend("Invalid JSON in configuration");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const guardrailData: {
|
||||
guardrail_id: string;
|
||||
guardrail: {
|
||||
guardrail_name: string;
|
||||
litellm_params: Record<string, unknown>;
|
||||
guardrail_info: Record<string, unknown>;
|
||||
};
|
||||
} = {
|
||||
guardrail_id: guardrailId,
|
||||
guardrail: {
|
||||
guardrail_name: values.guardrail_name,
|
||||
litellm_params,
|
||||
guardrail_info,
|
||||
},
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("No access token available");
|
||||
}
|
||||
|
||||
// Call the update endpoint
|
||||
const url = `/guardrails/${guardrailId}`;
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(guardrailData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(errorData || "Failed to update guardrail");
|
||||
}
|
||||
|
||||
NotificationsManager.success("Guardrail updated successfully");
|
||||
|
||||
// Reset and close
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update guardrail:", error);
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to update guardrail: " + (error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPiiConfiguration = () => {
|
||||
if (!guardrailSettings || !selectedProvider || selectedProvider !== "PresidioPII") return null;
|
||||
|
||||
return (
|
||||
<PiiConfiguration
|
||||
entities={guardrailSettings.supported_entities}
|
||||
actions={guardrailSettings.supported_actions}
|
||||
selectedEntities={selectedEntities}
|
||||
selectedActions={selectedActions}
|
||||
onEntitySelect={handleEntitySelect}
|
||||
onActionSelect={handleActionSelect}
|
||||
entityCategories={guardrailSettings.pii_entity_categories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProviderSpecificFields = () => {
|
||||
if (!selectedProvider) return null;
|
||||
|
||||
// For Presidio, we use the new PII configuration UI
|
||||
if (selectedProvider === "PresidioPII") {
|
||||
return renderPiiConfiguration();
|
||||
}
|
||||
|
||||
switch (selectedProvider) {
|
||||
case "Aporia":
|
||||
return (
|
||||
<Form.Item label="Aporia Configuration" name="config" tooltip="JSON configuration for Aporia">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"api_key": "your_aporia_api_key",
|
||||
"project_name": "your_project_name"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "AimSecurity":
|
||||
return (
|
||||
<Form.Item label="Aim Security Configuration" name="config" tooltip="JSON configuration for Aim Security">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"api_key": "your_aim_api_key"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "Bedrock":
|
||||
return (
|
||||
<Form.Item
|
||||
label="Amazon Bedrock Configuration"
|
||||
name="config"
|
||||
tooltip="JSON configuration for Amazon Bedrock guardrails"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"guardrail_id": "your_guardrail_id",
|
||||
"guardrail_version": "your_guardrail_version"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "CatoNetworks":
|
||||
return (
|
||||
<Form.Item label="Cato Networks Configuration" name="config" tooltip="JSON configuration for Cato Networks">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"api_key": "your_cato_api_key"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "GuardrailsAI":
|
||||
return (
|
||||
<Form.Item label="Guardrails.ai Configuration" name="config" tooltip="JSON configuration for Guardrails.ai">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"api_key": "your_guardrails_api_key",
|
||||
"guardrail_id": "your_guardrail_id"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "LakeraAI":
|
||||
return (
|
||||
<Form.Item label="Lakera AI Configuration" name="config" tooltip="JSON configuration for Lakera AI">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"api_key": "your_lakera_api_key"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "PromptInjection":
|
||||
return (
|
||||
<Form.Item
|
||||
label="Prompt Injection Configuration"
|
||||
name="config"
|
||||
tooltip="JSON configuration for prompt injection detection"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"threshold": 0.8
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Form.Item label="Custom Configuration" name="config" tooltip="JSON configuration for your custom guardrail">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`{
|
||||
"key1": "value1",
|
||||
"key2": "value2"
|
||||
}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Edit Guardrail" open={visible} onCancel={onClose} footer={null} width={700}>
|
||||
<Form form={form} layout="vertical" initialValues={initialValues}>
|
||||
<Form.Item
|
||||
name="guardrail_name"
|
||||
label="Guardrail Name"
|
||||
rules={[{ required: true, message: "Please enter a guardrail name" }]}
|
||||
>
|
||||
<TextInput placeholder="Enter a name for this guardrail" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="provider"
|
||||
label="Guardrail Provider"
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select a guardrail provider"
|
||||
onChange={handleProviderChange}
|
||||
disabled={true} // Disable changing provider in edit mode
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{Object.entries(getGuardrailProviders()).map(([key, value]) => (
|
||||
<Option key={key} value={key} label={value}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{guardrailLogoMap[value] && (
|
||||
<img
|
||||
src={resolveLogoSrc(guardrailLogoMap[value])}
|
||||
alt=""
|
||||
style={{
|
||||
height: "20px",
|
||||
width: "20px",
|
||||
marginRight: "8px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(e) => {
|
||||
// Hide broken image icon if image fails to load
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="mode"
|
||||
label="Mode"
|
||||
tooltip="How the guardrail should be applied"
|
||||
rules={[{ required: true, message: "Please select a mode" }]}
|
||||
>
|
||||
<Select>
|
||||
{(() => {
|
||||
const modes = getSupportedModesForProvider(guardrailSettings, selectedProvider) ?? [
|
||||
"pre_call",
|
||||
"post_call",
|
||||
];
|
||||
const currentModes = toModeArray(initialValues?.mode);
|
||||
const unsupportedCurrent = currentModes.filter((m) => !modes.includes(m));
|
||||
return [...unsupportedCurrent, ...modes].map((mode) => (
|
||||
<Option key={mode} value={mode}>
|
||||
{unsupportedCurrent.includes(mode)
|
||||
? `${mode} (not supported by ${selectedProvider}, pick another)`
|
||||
: mode}
|
||||
</Option>
|
||||
));
|
||||
})()}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="default_on"
|
||||
label="Always On"
|
||||
tooltip="If enabled, this guardrail will be applied to all requests by default"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="skip_system_message_choice"
|
||||
label="Skip system messages in guardrail"
|
||||
tooltip="Unified guardrails only: whether role: system content is omitted from guardrail input (LLM still receives full messages). Use global default follows litellm_settings.skip_system_message_in_guardrail."
|
||||
>
|
||||
<Select>
|
||||
<Option value="inherit">Use global default</Option>
|
||||
<Option value="yes">Yes — exclude from guardrail scan</Option>
|
||||
<Option value="no">No — always include in scan</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="skip_tool_message_choice"
|
||||
label="Skip tool messages in guardrail"
|
||||
tooltip="Unified guardrails only: whether role: tool content is omitted from guardrail input (LLM still receives full messages). Use global default follows litellm_settings.skip_tool_message_in_guardrail."
|
||||
>
|
||||
<Select>
|
||||
<Option value="inherit">Use global default</Option>
|
||||
<Option value="yes">Yes — exclude from guardrail scan</Option>
|
||||
<Option value="no">No — always include in scan</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{renderProviderSpecificFields()}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Update Guardrail
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditGuardrailForm;
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { getGuardrailLogoAndName } from "./guardrail_info_helpers";
|
||||
|
||||
const CONFIG_DELETE_HINT = "Config guardrails are defined in the config file and cannot be deleted from the dashboard.";
|
||||
|
||||
function GuardrailProviderCell({ provider }: { provider: string }) {
|
||||
const { logo, displayName } = getGuardrailLogoAndName(provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
className="size-4 shrink-0"
|
||||
onError={(event) => {
|
||||
(event.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-sm">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GuardrailRowActionsProps {
|
||||
guardrail: Guardrail;
|
||||
onDeleteClick: (guardrailId: string, guardrailName: string) => void;
|
||||
}
|
||||
|
||||
function GuardrailRowActions({ guardrail, onDeleteClick }: GuardrailRowActionsProps) {
|
||||
const isConfigGuardrail = guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open guardrail actions"
|
||||
data-testid={`guardrail-actions-${guardrail.guardrail_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isConfigGuardrail}
|
||||
data-testid="guardrail-action-delete"
|
||||
title={isConfigGuardrail ? CONFIG_DELETE_HINT : undefined}
|
||||
onClick={() => onDeleteClick(guardrail.guardrail_id, guardrail.guardrail_name || "Unnamed Guardrail")}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface GuardrailTableColumnsDeps {
|
||||
onGuardrailClick: (guardrailId: string) => void;
|
||||
onDeleteClick: (guardrailId: string, guardrailName: string) => void;
|
||||
}
|
||||
|
||||
export const getGuardrailTableColumns = ({
|
||||
onGuardrailClick,
|
||||
onDeleteClick,
|
||||
}: GuardrailTableColumnsDeps): ColumnDef<Guardrail>[] => [
|
||||
{
|
||||
id: "guardrail_id",
|
||||
accessorKey: "guardrail_id",
|
||||
meta: { title: "Guardrail ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Guardrail ID" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.guardrail_id}
|
||||
titleClassName="font-mono text-xs font-normal"
|
||||
onClick={() => onGuardrailClick(row.original.guardrail_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "guardrail_name",
|
||||
accessorKey: "guardrail_name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.guardrail_name;
|
||||
return (
|
||||
<span className="block truncate text-sm font-medium" title={name ?? undefined}>
|
||||
{name || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "provider",
|
||||
meta: { title: "Provider" },
|
||||
header: "Provider",
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <GuardrailProviderCell provider={row.original.litellm_params.guardrail} />,
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
meta: { title: "Mode" },
|
||||
header: "Mode",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.litellm_params.mode}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "default_on",
|
||||
meta: { title: "Default On" },
|
||||
header: "Default On",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isDefaultOn = !!row.original.litellm_params?.default_on;
|
||||
return (
|
||||
<StatusBadge tone={isDefaultOn ? "success" : "neutral"} label={isDefaultOn ? "Default On" : "Default Off"} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<GuardrailRowActions guardrail={row.original} onDeleteClick={onDeleteClick} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -1,56 +1,61 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import GuardrailTable from "./guardrail_table";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GuardrailDefinitionLocation } from "@/components/guardrails/types";
|
||||
import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types";
|
||||
|
||||
const baseProps = {
|
||||
isLoading: false,
|
||||
onDeleteClick: vi.fn(),
|
||||
onGuardrailClick: vi.fn(),
|
||||
};
|
||||
|
||||
const makeGuardrail = (overrides: Partial<Guardrail> = {}): Guardrail => ({
|
||||
guardrail_id: "gr-1",
|
||||
guardrail_name: "PII Redaction",
|
||||
litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: true },
|
||||
guardrail_info: null,
|
||||
created_at: "2021-01-01",
|
||||
updated_at: "2021-01-02",
|
||||
guardrail_definition_location: GuardrailDefinitionLocation.DB,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("GuardrailTable", () => {
|
||||
it("should render", () => {
|
||||
const { getByText } = render(
|
||||
<GuardrailTable
|
||||
guardrailsList={[]}
|
||||
isLoading={false}
|
||||
onDeleteClick={() => {}}
|
||||
accessToken={null}
|
||||
onGuardrailUpdated={() => {}}
|
||||
onGuardrailClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(getByText("Guardrail ID")).toBeInTheDocument();
|
||||
expect(getByText("Name")).toBeInTheDocument();
|
||||
expect(getByText("Provider")).toBeInTheDocument();
|
||||
expect(getByText("Mode")).toBeInTheDocument();
|
||||
expect(getByText("Default On")).toBeInTheDocument();
|
||||
expect(getByText("Created At")).toBeInTheDocument();
|
||||
expect(getByText("Updated At")).toBeInTheDocument();
|
||||
it("renders every column header", () => {
|
||||
render(<GuardrailTable guardrailsList={[]} {...baseProps} />);
|
||||
for (const header of ["Guardrail ID", "Name", "Provider", "Mode", "Default On", "Created At", "Updated At"]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should not allow deletion of config guardrails", () => {
|
||||
const { getByTestId } = render(
|
||||
<GuardrailTable
|
||||
guardrailsList={[
|
||||
{
|
||||
guardrail_id: "1",
|
||||
guardrail_name: "Guardrail 1",
|
||||
litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: true },
|
||||
guardrail_info: null,
|
||||
created_at: "2021-01-01",
|
||||
updated_at: "2021-01-01",
|
||||
guardrail_definition_location: GuardrailDefinitionLocation.CONFIG,
|
||||
},
|
||||
]}
|
||||
isLoading={false}
|
||||
onDeleteClick={() => {}}
|
||||
accessToken={null}
|
||||
onGuardrailUpdated={() => {}}
|
||||
onGuardrailClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
it("deletes a DB guardrail through the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
const guardrail = makeGuardrail({ guardrail_id: "gr-9", guardrail_name: "Toxicity Filter" });
|
||||
render(<GuardrailTable guardrailsList={[guardrail]} {...baseProps} onDeleteClick={onDeleteClick} />);
|
||||
|
||||
const deleteGuardrailButton = getByTestId("config-delete-icon");
|
||||
expect(deleteGuardrailButton).toBeInTheDocument();
|
||||
expect(deleteGuardrailButton).toHaveClass("cursor-not-allowed text-gray-400");
|
||||
expect(deleteGuardrailButton).toHaveAttribute(
|
||||
"title",
|
||||
"Config guardrail cannot be deleted on the dashboard. Please delete it from the config file.",
|
||||
);
|
||||
await user.click(screen.getByTestId("guardrail-actions-gr-9"));
|
||||
await user.click(await screen.findByTestId("guardrail-action-delete"));
|
||||
|
||||
expect(onDeleteClick).toHaveBeenCalledWith("gr-9", "Toxicity Filter");
|
||||
});
|
||||
|
||||
it("disables deletion for config guardrails so they cannot be removed from the dashboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
const guardrail = makeGuardrail({
|
||||
guardrail_id: "cfg-1",
|
||||
guardrail_name: "Config Guardrail",
|
||||
guardrail_definition_location: GuardrailDefinitionLocation.CONFIG,
|
||||
});
|
||||
render(<GuardrailTable guardrailsList={[guardrail]} {...baseProps} onDeleteClick={onDeleteClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("guardrail-actions-cfg-1"));
|
||||
const deleteItem = await screen.findByTestId("guardrail-action-delete");
|
||||
|
||||
expect(deleteItem).toHaveAttribute("data-disabled");
|
||||
expect(onDeleteClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,284 +1,61 @@
|
|||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon } from "@tremor/react";
|
||||
import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip } from "antd";
|
||||
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
getGuardrailLogoAndName,
|
||||
guardrail_provider_map,
|
||||
skipSystemMessageToChoice,
|
||||
skipToolMessageToChoice,
|
||||
} from "./guardrail_info_helpers";
|
||||
import EditGuardrailForm from "./edit_guardrail_form";
|
||||
import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types";
|
||||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Guardrail } from "@/components/guardrails/types";
|
||||
|
||||
import { getGuardrailTableColumns } from "./guardrailTableColumns";
|
||||
|
||||
interface GuardrailTableProps {
|
||||
guardrailsList: Guardrail[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (guardrailId: string, guardrailName: string) => void;
|
||||
accessToken: string | null;
|
||||
onGuardrailUpdated: () => void;
|
||||
isAdmin?: boolean;
|
||||
onGuardrailClick: (id: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No guardrails yet</div>
|
||||
<div className="text-sm text-muted-foreground">Add a guardrail to start filtering requests and responses.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GuardrailTable: React.FC<GuardrailTableProps> = ({
|
||||
guardrailsList,
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
accessToken,
|
||||
onGuardrailUpdated,
|
||||
isAdmin = false,
|
||||
onGuardrailClick,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [selectedGuardrail, setSelectedGuardrail] = useState<Guardrail | null>(null);
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const handleEditClick = (guardrail: Guardrail) => {
|
||||
setSelectedGuardrail(guardrail);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setEditModalVisible(false);
|
||||
setSelectedGuardrail(null);
|
||||
onGuardrailUpdated();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Guardrail>[] = [
|
||||
{
|
||||
header: "Guardrail ID",
|
||||
accessorKey: "guardrail_id",
|
||||
cell: (info: any) => <IdCell value={info.getValue()} onClick={onGuardrailClick} />,
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "guardrail_name",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
return (
|
||||
<Tooltip title={guardrail.guardrail_name}>
|
||||
<span className="text-xs font-medium">{guardrail.guardrail_name || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Provider",
|
||||
accessorKey: "litellm_params.guardrail",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
const { logo, displayName } = getGuardrailLogoAndName(guardrail.litellm_params.guardrail);
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={`${displayName} logo`}
|
||||
className="w-4 h-4"
|
||||
onError={(e) => {
|
||||
// Hide broken image
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Mode",
|
||||
accessorKey: "litellm_params.mode",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
return <span className="text-xs">{guardrail.litellm_params.mode}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Default On",
|
||||
accessorKey: "litellm_params.default_on",
|
||||
cell: ({ row }) => {
|
||||
const isDefaultOn = !!row.original.litellm_params?.default_on;
|
||||
return (
|
||||
<StatusBadge tone={isDefaultOn ? "success" : "neutral"} label={isDefaultOn ? "Default On" : "Default Off"} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
const isConfigGuardrail = guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
{isConfigGuardrail ? (
|
||||
<Tooltip title="Config guardrail cannot be deleted on the dashboard. Please delete it from the config file.">
|
||||
<Icon
|
||||
data-testid="config-delete-icon"
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
className="cursor-not-allowed text-gray-400"
|
||||
title="Config guardrail cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
aria-label="Delete guardrail (config)"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Delete guardrail">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
guardrail.guardrail_id &&
|
||||
onDeleteClick(guardrail.guardrail_id, guardrail.guardrail_name || "Unnamed Guardrail")
|
||||
}
|
||||
className="cursor-pointer hover:text-red-500"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: guardrailsList,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
});
|
||||
const columns = useMemo(
|
||||
() => getGuardrailTableColumns({ onGuardrailClick, onDeleteClick }),
|
||||
[onGuardrailClick, onDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 ${
|
||||
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
|
||||
}`}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{header.id !== "actions" && (
|
||||
<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" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : guardrailsList.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
|
||||
cell.column.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No guardrails found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{selectedGuardrail && (
|
||||
<EditGuardrailForm
|
||||
visible={editModalVisible}
|
||||
onClose={() => setEditModalVisible(false)}
|
||||
accessToken={accessToken}
|
||||
onSuccess={handleEditSuccess}
|
||||
guardrailId={selectedGuardrail.guardrail_id || ""}
|
||||
fullLitellmParams={selectedGuardrail.litellm_params}
|
||||
initialValues={{
|
||||
guardrail_name: selectedGuardrail.guardrail_name || "",
|
||||
provider:
|
||||
Object.keys(guardrail_provider_map).find(
|
||||
(key) => guardrail_provider_map[key] === selectedGuardrail?.litellm_params.guardrail,
|
||||
) || "",
|
||||
mode: selectedGuardrail.litellm_params.mode,
|
||||
default_on: selectedGuardrail.litellm_params.default_on,
|
||||
pii_entities_config: selectedGuardrail.litellm_params.pii_entities_config,
|
||||
skip_system_message_choice: skipSystemMessageToChoice(
|
||||
selectedGuardrail.litellm_params?.skip_system_message_in_guardrail,
|
||||
),
|
||||
skip_tool_message_choice: skipToolMessageToChoice(
|
||||
selectedGuardrail.litellm_params?.skip_tool_message_in_guardrail,
|
||||
),
|
||||
...selectedGuardrail.guardrail_info,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={guardrailsList}
|
||||
columns={columns}
|
||||
getRowId={(guardrail, index) => guardrail.guardrail_id || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading guardrails…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,23 @@ describe("DataTable loading", () => {
|
|||
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
|
||||
});
|
||||
|
||||
it("gives compact skeleton rows the same height as loaded rows so loading does not shrink the table", () => {
|
||||
const { rerender } = render(
|
||||
<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" isLoading />,
|
||||
);
|
||||
const skeletonRow = screen.getAllByTestId("skeleton-row").at(0);
|
||||
const loadedRowHeight = "h-8";
|
||||
expect(skeletonRow?.className).toContain(loadedRowHeight);
|
||||
|
||||
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" />);
|
||||
expect(document.querySelector("[data-row-id]")?.className).toContain(loadedRowHeight);
|
||||
});
|
||||
|
||||
it("does not force the compact height on default-size skeleton rows", () => {
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
|
||||
expect(screen.getAllByTestId("skeleton-row").at(0)?.className).not.toContain("h-8");
|
||||
});
|
||||
|
||||
it("varies skeleton shape and width per column instead of one fixed bar", () => {
|
||||
const columns: ColumnDef<Person, unknown>[] = [
|
||||
{ accessorKey: "name", header: "Name", meta: { skeleton: "twoLine" }, cell: () => null },
|
||||
|
|
|
|||
|
|
@ -389,7 +389,11 @@ function SkeletonRows<TData>({
|
|||
return (
|
||||
<Fragment>
|
||||
{rowKeys.map((rowKey) => (
|
||||
<TableRow key={`skeleton-${rowKey}`} className="hover:bg-transparent" data-testid="skeleton-row">
|
||||
<TableRow
|
||||
key={`skeleton-${rowKey}`}
|
||||
className={cn("hover:bg-transparent", size === "compact" ? "h-8" : "")}
|
||||
data-testid="skeleton-row"
|
||||
>
|
||||
{cells.map((column, columnKey) => (
|
||||
<TableCell key={column?.id ?? columnKey} className={size === "compact" ? "px-2 py-1" : ""}>
|
||||
<SkeletonCell column={column} index={columnKey} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue