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:
yuneng-jiang 2026-07-14 17:35:27 -07:00 • committed by GitHub
parent 32af83d63a
commit f8c49f51cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 324 additions and 854 deletions

View file

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

View file

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

View file

@ -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)}
/>
)}

View file

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

View file

@ -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>
),
},
];

View file

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

View file

@ -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"
/>
);
};

View file

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

View file

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