Merge pull request #36832 from BerriAI/litellm_/quizzical-jennings-5df447

refactor(ui): migrate guardrails to shadcn
This commit is contained in:
yuneng-jiang 2026-08-13 18:30:56 -07:00 • committed by GitHub
commit 89f81919cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2283 additions and 1512 deletions

View file

@ -323,28 +323,12 @@
"count": 5
}
},
"src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -383,11 +367,6 @@
"count": 2
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": {
"no-nested-ternary": {
"count": 1
@ -400,14 +379,8 @@
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
@ -416,62 +389,20 @@
"src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.tsx": {
"local/no-complex-jsx-arrow": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.tsx": {
"max-params": {
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx": {
"no-nested-ternary": {
"count": 6
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -484,9 +415,6 @@
"src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/guardrail_garden_card.tsx": {
@ -497,9 +425,6 @@
"src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": {
@ -565,23 +490,14 @@
"src/app/(dashboard)/guardrails/_components/pii_components.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/pii_configuration.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/purity": {
"count": 1
}

View file

@ -1,13 +1,12 @@
import React, { useState } from "react";
import { Button } from "@tremor/react";
import { Input, Typography, Tooltip } from "antd";
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { Copy, Info } from "lucide-react";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import GuardrailTestResults from "./GuardrailTestResults";
const { TextArea } = Input;
const { Text } = Typography;
interface GuardrailTestPanelProps {
guardrailNames: string[];
onSubmit: (text: string, metadata?: Record<string, unknown> | null) => void;
@ -108,23 +107,23 @@ export function GuardrailTestPanel({
return (
<div className="space-y-4 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center space-x-3">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h2 className="text-lg font-semibold text-gray-900">Test Guardrails:</h2>
<div className="mb-1 flex items-center space-x-2">
<h2 className="text-lg font-semibold">Test Guardrails:</h2>
<div className="flex flex-wrap gap-2">
{guardrailNames.map((name) => (
<div
key={name}
className="inline-flex items-center space-x-1 bg-blue-50 px-3 py-1 rounded-md border border-blue-200"
className="inline-flex items-center space-x-1 rounded-md border border-blue-200 bg-blue-50 px-3 py-1"
>
<span className="font-mono text-blue-700 font-medium text-sm">{name}</span>
<span className="font-mono text-sm font-medium text-blue-700">{name}</span>
</div>
))}
</div>
</div>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Test {guardrailNames.length > 1 ? "guardrails" : "guardrail"} and compare results
</p>
</div>
@ -135,46 +134,63 @@ export function GuardrailTestPanel({
<div className="flex-1 overflow-auto space-y-4">
<div className="space-y-3">
<div>
<div className="flex justify-between items-center mb-2">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Input Text</label>
<Tooltip title="Press Enter to submit. Use Shift+Enter for new line.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
<label className="text-sm font-medium">Input Text</label>
<Tooltip>
<TooltipTrigger
render={
<span className="cursor-help text-muted-foreground">
<Info className="size-3.5" />
</span>
}
/>
<TooltipContent>Press Enter to submit. Use Shift+Enter for new line.</TooltipContent>
</Tooltip>
</div>
{inputText && (
<Button size="xs" variant="secondary" icon={CopyOutlined} onClick={handleCopyInput}>
<Button size="sm" variant="secondary" onClick={handleCopyInput}>
<Copy />
Copy Input
</Button>
)}
</div>
<TextArea
<Textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter text to test with guardrails..."
rows={8}
className="font-mono text-sm"
className="font-mono text-sm field-sizing-fixed"
/>
<div className="flex justify-between items-center mt-1">
<Text className="text-xs text-gray-500">
Press <kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded-sm text-xs">Enter</kbd> to
submit •{" "}
<kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded-sm text-xs">Shift+Enter</kbd> for
new line
</Text>
<Text className="text-xs text-gray-500">Characters: {inputText.length}</Text>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Press <kbd className="rounded-sm border border-border bg-muted px-1 py-0.5 text-xs">Enter</kbd> to
submit • <kbd className="rounded-sm border border-border bg-muted px-1 py-0.5 text-xs">Shift+Enter</kbd>{" "}
for new line
</span>
<span className="text-xs text-muted-foreground">Characters: {inputText.length}</span>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">Metadata (optional)</label>
<Tooltip title="JSON object forwarded to the guardrail as request_data['metadata']. Custom guardrails can read per-request configuration from it.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
<div className="mb-2 flex items-center gap-2">
<label className="text-sm font-medium">Metadata (optional)</label>
<Tooltip>
<TooltipTrigger
render={
<span className="cursor-help text-muted-foreground">
<Info className="size-3.5" />
</span>
}
/>
<TooltipContent>
JSON object forwarded to the guardrail as request_data[&apos;metadata&apos;]. Custom guardrails can
read per-request configuration from it.
</TooltipContent>
</Tooltip>
</div>
<TextArea
<Textarea
value={metadataText}
onChange={(e) => {
setMetadataText(e.target.value);
@ -184,18 +200,20 @@ export function GuardrailTestPanel({
}}
placeholder='{"forbidden_topics": ["tax", "finance"]}'
rows={3}
className="font-mono text-sm"
status={metadataError ? "error" : undefined}
className="font-mono text-sm field-sizing-fixed"
aria-invalid={metadataError ? true : undefined}
/>
{metadataError && (
<Text type="danger" className="text-xs">
{metadataError}
</Text>
)}
{metadataError && <span className="text-xs text-destructive">{metadataError}</span>}
</div>
<div className="pt-2">
<Button onClick={handleSubmit} loading={isLoading} disabled={!inputText.trim()} className="w-full">
<Button
onClick={handleSubmit}
disabled={!inputText.trim() || isLoading}
aria-busy={isLoading}
className="w-full"
>
{isLoading && <UiLoadingSpinner className="size-4" />}
{isLoading
? `Testing ${guardrailNames.length} guardrail${guardrailNames.length > 1 ? "s" : ""}...`
: `Test ${guardrailNames.length} guardrail${guardrailNames.length > 1 ? "s" : ""}`}

View file

@ -1,9 +1,11 @@
import React, { useState } from "react";
import { Card, List, Empty, Spin, Input, Typography } from "antd";
import { ExperimentOutlined, SearchOutlined } from "@ant-design/icons";
import { FlaskConical, Search } from "lucide-react";
import GuardrailTestPanel from "./GuardrailTestPanel";
import { applyGuardrail } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Card, CardContent } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface GuardrailItem {
guardrail_id?: string;
@ -112,115 +114,110 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
return (
<div className="w-full h-[calc(100vh-200px)]">
<Card className="h-full" styles={{ body: { padding: 0, height: "100%" } }}>
<div className="flex h-full">
{/* Left Sidebar - Guardrails List */}
<div className="w-1/4 border-r border-gray-200 flex flex-col overflow-hidden">
<div className="p-4 border-b border-gray-200">
<div className="mb-3">
<h3 className="text-lg font-semibold mb-3">Guardrails</h3>
<Input
prefix={<SearchOutlined />}
placeholder="Search guardrails..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Card className="h-full overflow-hidden py-0">
<CardContent className="h-full p-0">
<div className="flex h-full">
{/* Left Sidebar - Guardrails List */}
<div className="flex w-1/4 flex-col overflow-hidden border-r border-border">
<div className="border-b border-border p-4">
<div className="mb-3">
<h3 className="mb-3 text-lg font-semibold">Guardrails</h3>
<InputGroup>
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search guardrails..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
</div>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex h-32 items-center justify-center" aria-busy="true">
<UiLoadingSpinner className="size-6 text-muted-foreground" />
</div>
) : filteredGuardrails.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? "No guardrails match your search" : "No guardrails available"}
</div>
) : (
<ul className="m-0 list-none p-0">
{filteredGuardrails.map((guardrail) => (
<li
key={guardrail.guardrail_id ?? guardrail.guardrail_name}
onClick={() => {
if (guardrail.guardrail_name) {
toggleGuardrailSelection(guardrail.guardrail_name);
}
}}
className={`cursor-pointer border-b border-border py-3 pr-4 pl-6 transition-colors hover:bg-muted/40 ${
selectedGuardrails.has(guardrail.guardrail_name || "")
? "border-l-4 border-l-primary bg-accent"
: "border-l-4 border-l-transparent"
}`}
>
<div className="flex items-center space-x-2">
<FlaskConical className="size-4 text-muted-foreground" />
<span className="font-medium">{guardrail.guardrail_name}</span>
</div>
<div className="mt-1 space-y-1 text-xs">
<div>
<span className="font-medium">Type: </span>
<span className="text-muted-foreground">{guardrail.litellm_params.guardrail}</span>
</div>
<div>
<span className="font-medium">Mode: </span>
<span className="text-muted-foreground">{guardrail.litellm_params.mode}</span>
</div>
</div>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-border bg-muted/40 p-3">
<span className="text-xs text-muted-foreground">
{selectedGuardrails.size} of {filteredGuardrails.length} selected
</span>
</div>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Spin />
</div>
) : filteredGuardrails.length === 0 ? (
<div className="p-4">
<Empty description={searchQuery ? "No guardrails match your search" : "No guardrails available"} />
</div>
) : (
<List
dataSource={filteredGuardrails}
renderItem={(guardrail) => (
<List.Item
onClick={() => {
if (guardrail.guardrail_name) {
toggleGuardrailSelection(guardrail.guardrail_name);
}
}}
style={{ paddingLeft: 24, paddingRight: 16 }}
className={`cursor-pointer hover:bg-gray-50 transition-colors ${
selectedGuardrails.has(guardrail.guardrail_name || "")
? "bg-blue-50 border-l-4 border-l-blue-500"
: "border-l-4 border-l-transparent"
}`}
>
<List.Item.Meta
title={
<div className="flex items-center space-x-2">
<ExperimentOutlined className="text-gray-400" />
<span className="font-medium text-gray-900">{guardrail.guardrail_name}</span>
</div>
}
description={
<div className="text-xs space-y-1 mt-1">
<div>
<span className="font-medium">Type: </span>
<span className="text-gray-600">{guardrail.litellm_params.guardrail}</span>
</div>
<div>
<span className="font-medium">Mode: </span>
<span className="text-gray-600">{guardrail.litellm_params.mode}</span>
</div>
</div>
}
/>
</List.Item>
)}
/>
)}
</div>
{/* Right Panel - Test Area */}
<div className="flex w-3/4 flex-col">
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="mb-0 text-xl font-semibold">Guardrail Testing Playground</h2>
</div>
<div className="p-3 border-t border-gray-200 bg-gray-50">
<Typography.Text className="text-xs text-gray-600">
{selectedGuardrails.size} of {filteredGuardrails.length} selected
</Typography.Text>
<div className="flex-1 overflow-auto p-4">
{selectedGuardrails.size === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<FlaskConical className="mb-4 size-12" />
<p className="mb-2 text-lg font-medium">Select Guardrails to Test</p>
<p className="max-w-md text-center">
Choose one or more guardrails from the left sidebar to start testing and comparing results.
</p>
</div>
) : (
<div className="h-full">
<GuardrailTestPanel
guardrailNames={Array.from(selectedGuardrails)}
onSubmit={handleTestGuardrails}
results={testResults.length > 0 ? testResults : null}
errors={testErrors.length > 0 ? testErrors : null}
isLoading={isTesting}
onClose={() => setSelectedGuardrails(new Set())}
/>
</div>
)}
</div>
</div>
</div>
{/* Right Panel - Test Area */}
<div className="w-3/4 flex flex-col bg-white">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<Typography.Title level={2} className="text-xl font-semibold mb-0">
Guardrail Testing Playground
</Typography.Title>
</div>
<div className="flex-1 overflow-auto p-4">
{selectedGuardrails.size === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<ExperimentOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
<Typography.Paragraph className="text-lg font-medium text-gray-600 mb-2">
Select Guardrails to Test
</Typography.Paragraph>
<Typography.Paragraph className="text-center text-gray-500 max-w-md">
Choose one or more guardrails from the left sidebar to start testing and comparing results.
</Typography.Paragraph>
</div>
) : (
<div className="h-full">
<GuardrailTestPanel
guardrailNames={Array.from(selectedGuardrails)}
onSubmit={handleTestGuardrails}
results={testResults.length > 0 ? testResults : null}
errors={testErrors.length > 0 ? testErrors : null}
isLoading={isTesting}
onClose={() => setSelectedGuardrails(new Set())}
/>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);

View file

@ -1,7 +1,8 @@
import React, { useState } from "react";
import { Button, Card } from "@tremor/react";
import { CopyOutlined, CheckCircleOutlined, ClockCircleOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import { Check, ChevronDown, ChevronRight, Clock, Copy } from "lucide-react";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface TestResult {
guardrailName: string;
@ -66,39 +67,38 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
}
return (
<div className="space-y-3 pt-4 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">Results</h3>
<div className="space-y-3 border-t border-border pt-4">
<h3 className="text-sm font-semibold">Results</h3>
{/* Success Results */}
{results &&
results.map((result) => {
const isCollapsed = collapsedResults.has(result.guardrailName);
return (
<Card key={result.guardrailName} className="bg-green-50 border-green-200">
<div className="space-y-3">
<Card key={result.guardrailName} className="border-emerald-200 bg-emerald-50">
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div
className="flex items-center space-x-2 cursor-pointer flex-1"
className="flex flex-1 cursor-pointer items-center space-x-2"
onClick={() => toggleResultCollapse(result.guardrailName)}
>
{isCollapsed ? (
<RightOutlined className="text-gray-500 text-xs" />
<ChevronRight className="size-3 text-muted-foreground" />
) : (
<DownOutlined className="text-gray-500 text-xs" />
<ChevronDown className="size-3 text-muted-foreground" />
)}
<CheckCircleOutlined className="text-green-600 text-lg" />
<span className="text-sm font-medium text-green-800">{result.guardrailName}</span>
<Check className="size-4 text-emerald-600" />
<span className="text-sm font-medium text-emerald-800">{result.guardrailName}</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center space-x-1 text-xs text-gray-600">
<ClockCircleOutlined />
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Clock className="size-3" />
<span className="font-medium">{result.latency}ms</span>
</div>
{!isCollapsed && (
<Button
size="xs"
size="sm"
variant="secondary"
icon={CopyOutlined}
onClick={async () => {
const success = await copyToClipboard(result.response_text);
if (success) {
@ -108,6 +108,7 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
}
}}
>
<Copy />
Copy
</Button>
)}
@ -115,18 +116,18 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
</div>
{!isCollapsed && (
<>
<div className="bg-white border border-green-200 rounded-sm p-3">
<label className="text-xs font-medium text-gray-600 mb-2 block">Output Text</label>
<div className="font-mono text-sm text-gray-900 whitespace-pre-wrap wrap-break-word">
<div className="rounded-sm border border-emerald-200 bg-background p-3">
<label className="mb-2 block text-xs font-medium text-muted-foreground">Output Text</label>
<div className="font-mono text-sm whitespace-pre-wrap wrap-break-word">
{result.response_text}
</div>
</div>
<div className="text-xs text-gray-600">
<div className="text-xs text-muted-foreground">
<span className="font-medium">Characters:</span> {result.response_text.length}
</div>
</>
)}
</div>
</CardContent>
</Card>
);
})}
@ -136,40 +137,42 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
errors.map((errorItem) => {
const isCollapsed = collapsedResults.has(errorItem.guardrailName);
return (
<Card key={errorItem.guardrailName} className="bg-red-50 border-red-200">
<div className="flex items-start space-x-2">
<div className="cursor-pointer mt-0.5" onClick={() => toggleResultCollapse(errorItem.guardrailName)}>
{isCollapsed ? (
<RightOutlined className="text-gray-500 text-xs" />
) : (
<DownOutlined className="text-gray-500 text-xs" />
)}
</div>
<div className="text-red-600 mt-0.5">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<p
className="text-sm font-medium text-red-800 cursor-pointer"
onClick={() => toggleResultCollapse(errorItem.guardrailName)}
>
{errorItem.guardrailName} - Error
</p>
<div className="flex items-center space-x-1 text-xs text-gray-600">
<ClockCircleOutlined />
<span className="font-medium">{errorItem.latency}ms</span>
</div>
<Card key={errorItem.guardrailName} className="border-red-200 bg-red-50">
<CardContent>
<div className="flex items-start space-x-2">
<div className="mt-0.5 cursor-pointer" onClick={() => toggleResultCollapse(errorItem.guardrailName)}>
{isCollapsed ? (
<ChevronRight className="size-3 text-muted-foreground" />
) : (
<ChevronDown className="size-3 text-muted-foreground" />
)}
</div>
<div className="mt-0.5 text-destructive">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<div className="mb-1 flex items-center justify-between">
<p
className="cursor-pointer text-sm font-medium text-red-800"
onClick={() => toggleResultCollapse(errorItem.guardrailName)}
>
{errorItem.guardrailName} - Error
</p>
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Clock className="size-3" />
<span className="font-medium">{errorItem.latency}ms</span>
</div>
</div>
{!isCollapsed && <p className="mt-1 text-sm text-red-700">{errorItem.error.message}</p>}
</div>
{!isCollapsed && <p className="text-sm text-red-700 mt-1">{errorItem.error.message}</p>}
</div>
</div>
</CardContent>
</Card>
);
})}

View file

@ -35,10 +35,19 @@ vi.mock("./guardrail_info", () => ({
default: () => <div>Mock Guardrail Info View</div>,
}));
vi.mock("./GuardrailTestPlayground", () => ({
__esModule: true,
default: () => <div>Mock Guardrail Test Playground</div>,
}));
vi.mock("./GuardrailTestPlayground", async () => {
const { useState } = await import("react");
const MockGuardrailTestPlayground = () => {
const [draft, setDraft] = useState("");
return (
<div>
<div>Mock Guardrail Test Playground</div>
<input aria-label="playground draft" value={draft} onChange={(e) => setDraft(e.target.value)} />
</div>
);
};
return { __esModule: true, default: MockGuardrailTestPlayground };
});
vi.mock("./TeamGuardrailsTab", () => ({
TeamGuardrailsTab: () => <div>Mock Team Guardrails Tab</div>,
@ -129,6 +138,28 @@ describe("GuardrailsPanel", () => {
expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2);
});
it("should mount every tab panel up front so panel state survives tab switches", async () => {
render(<GuardrailsPanel {...defaultProps} />);
expect(await screen.findByLabelText("playground draft")).toBeInTheDocument();
expect(screen.getByText("Mock Team Guardrails Tab")).toBeInTheDocument();
});
it("should keep test playground state when switching tabs away and back", async () => {
render(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Test Playground"));
const draft = await screen.findByLabelText("playground draft");
fireEvent.change(draft, { target: { value: "keep me" } });
expect(draft).toHaveValue("keep me");
fireEvent.click(screen.getByText("Guardrails"));
fireEvent.click(screen.getByText("Test Playground"));
expect(await screen.findByLabelText("playground draft")).toHaveValue("keep me");
});
it("should not delete anything when the modal is cancelled", async () => {
render(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Tabs } from "antd";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChevronDown, Code, Plus } from "lucide-react";
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
import { buttonVariants } from "@/components/ui/button";
@ -125,118 +125,119 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<Tabs
defaultActiveKey="guardrails"
items={[
...(isAdmin
? [
{
key: "garden",
label: "Guardrail Garden",
children: <GuardrailGarden accessToken={accessToken} onGuardrailCreated={handleSuccess} />,
},
{
key: "guardrails",
label: "Guardrails",
children: (
<>
<div className="flex justify-between items-center mb-4">
<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>
<Tabs defaultValue="guardrails">
<TabsList>
{isAdmin && (
<>
<TabsTrigger value="garden" className="flex-none">
Guardrail Garden
</TabsTrigger>
<TabsTrigger value="guardrails" className="flex-none">
Guardrails
</TabsTrigger>
<TabsTrigger value="playground" className="flex-none" disabled={!accessToken}>
Test Playground
</TabsTrigger>
</>
)}
<TabsTrigger value="submitted" className="flex-none">
Submitted Guardrails
</TabsTrigger>
</TabsList>
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
/>
) : (
<GuardrailTable
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
/>
)}
{isAdmin && (
<>
<TabsContent value="garden" keepMounted>
<GuardrailGarden accessToken={accessToken} onGuardrailCreated={handleSuccess} />
</TabsContent>
<AddGuardrailForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
<TabsContent value="guardrails" keepMounted>
<div className="flex justify-between items-center mb-4">
<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>
<CustomCodeModal
visible={isCustomCodeModalVisible}
onClose={handleCloseCustomCodeModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
/>
) : (
<GuardrailTable
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
/>
)}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Guardrail"
message={`Are you sure you want to delete guardrail: ${guardrailToDelete?.guardrail_name}? This action cannot be undone.`}
resourceInformationTitle="Guardrail Information"
resourceInformation={[
{ label: "Name", value: guardrailToDelete?.guardrail_name },
{ label: "ID", value: guardrailToDelete?.guardrail_id, code: true },
{ label: "Provider", value: providerDisplayName },
{ label: "Mode", value: guardrailToDelete?.litellm_params.mode },
{
label: "Default On",
value: guardrailToDelete?.litellm_params.default_on ? "Yes" : "No",
},
]}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
confirmLoading={isDeleting}
/>
</>
),
},
{
key: "playground",
label: "Test Playground",
disabled: !accessToken,
children: (
<GuardrailTestPlayground
guardrailsList={guardrailsList}
isLoading={isLoading}
accessToken={accessToken}
onClose={() => {}}
/>
),
},
]
: []),
{
key: "submitted",
label: "Submitted Guardrails",
children: <TeamGuardrailsTab accessToken={accessToken} />,
},
]}
/>
<AddGuardrailForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
<CustomCodeModal
visible={isCustomCodeModalVisible}
onClose={handleCloseCustomCodeModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Guardrail"
message={`Are you sure you want to delete guardrail: ${guardrailToDelete?.guardrail_name}? This action cannot be undone.`}
resourceInformationTitle="Guardrail Information"
resourceInformation={[
{ label: "Name", value: guardrailToDelete?.guardrail_name },
{ label: "ID", value: guardrailToDelete?.guardrail_id, code: true },
{ label: "Provider", value: providerDisplayName },
{ label: "Mode", value: guardrailToDelete?.litellm_params.mode },
{
label: "Default On",
value: guardrailToDelete?.litellm_params.default_on ? "Yes" : "No",
},
]}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
confirmLoading={isDeleting}
/>
</TabsContent>
<TabsContent value="playground" keepMounted>
<GuardrailTestPlayground
guardrailsList={guardrailsList}
isLoading={isLoading}
accessToken={accessToken}
onClose={() => {}}
/>
</TabsContent>
</>
)}
<TabsContent value="submitted" keepMounted>
<TeamGuardrailsTab accessToken={accessToken} />
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -1,11 +1,11 @@
import React from "react";
import { Typography, Select, Tag, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/shared/DataTable";
const { Text } = Typography;
const { Option } = Select;
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS, SEVERITY_ITEMS } from "./action_options";
interface ContentCategory {
id: string;
@ -38,14 +38,8 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
const { category, display_name: displayName } = row.original;
return (
<div>
<Text strong>{displayName}</Text>
{displayName !== category && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{category}
</Text>
</div>
)}
<span className="font-semibold">{displayName}</span>
{displayName !== category && <div className="text-xs text-muted-foreground">{category}</div>}
</div>
);
},
@ -57,23 +51,26 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
cell: ({ row }) => {
const { id, severity_threshold: severity } = row.original;
if (readOnly) {
const colorMap = {
high: "red",
medium: "orange",
low: "yellow",
} as const;
return <Tag color={colorMap[severity as keyof typeof colorMap]}>{severity.toUpperCase()}</Tag>;
return <Badge variant={severity === "high" ? "destructive" : "secondary"}>{severity.toUpperCase()}</Badge>;
}
return (
<Select
items={SEVERITY_ITEMS}
value={severity}
onChange={(value) => onSeverityChange?.(id, value as "high" | "medium" | "low")}
style={{ width: 150 }}
size="small"
onValueChange={(value: string | null) =>
value && onSeverityChange?.(id, value as "high" | "medium" | "low")
}
>
<Option value="high">High</Option>
<Option value="medium">Medium</Option>
<Option value="low">Low</Option>
<SelectTrigger size="sm" className="w-[150px]" aria-label="Severity Threshold">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{SEVERITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
@ -85,17 +82,24 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
cell: ({ row }) => {
const { action, id } = row.original;
if (readOnly) {
return <Tag color={action === "BLOCK" ? "red" : "blue"}>{action}</Tag>;
return <Badge variant={action === "BLOCK" ? "destructive" : "secondary"}>{action}</Badge>;
}
return (
<Select
items={ACTION_ITEMS}
value={action}
onChange={(value) => onActionChange?.(id, value as "BLOCK" | "MASK")}
style={{ width: 120 }}
size="small"
onValueChange={(value: string | null) => value && onActionChange?.(id, value as "BLOCK" | "MASK")}
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
@ -108,7 +112,8 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
id: "actions",
size: 100,
cell: ({ row }) => (
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove?.(row.original.id)}>
<Button variant="ghost" size="sm" onClick={() => onRemove?.(row.original.id)}>
<Trash2 />
Delete
</Button>
),
@ -116,7 +121,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
}
if (categories.length === 0) {
return <div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>No categories configured.</div>;
return <div className="py-10 text-center text-muted-foreground">No categories configured.</div>;
}
return <DataTable data={categories} columns={columns} getRowId={(row) => row.id} size="compact" />;

View file

@ -1,12 +1,22 @@
import React from "react";
import { Card, Typography, Select, Tag, Collapse, Button } from "antd";
import { DeleteOutlined, PlusOutlined, FileTextOutlined } from "@ant-design/icons";
import { ChevronRight, FileText, Plus, Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { getCategoryYaml } from "@/components/networking";
import { DataTable } from "@/components/shared/DataTable";
const { Title, Text } = Typography;
const { Option } = Select;
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS, SEVERITY_ITEMS } from "./action_options";
interface ContentCategory {
name: string;
@ -170,10 +180,8 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
const category = availableCategories.find((c) => c.name === row.original.category);
return (
<div>
<div style={{ fontWeight: 500 }}>{row.original.display_name}</div>
{category?.description && (
<div style={{ fontSize: "12px", color: "#888", marginTop: "4px" }}>{category.description}</div>
)}
<div className="font-medium">{row.original.display_name}</div>
{category?.description && <div className="mt-1 text-xs text-muted-foreground">{category.description}</div>}
</div>
);
},
@ -184,16 +192,20 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
size: 150,
cell: ({ row }) => (
<Select
items={ACTION_ITEMS}
value={row.original.action}
onChange={(value) => onCategoryUpdate(row.original.id, "action", value)}
style={{ width: "100%" }}
onValueChange={(value: string | null) => value && onCategoryUpdate(row.original.id, "action", value)}
>
<Option value="BLOCK">
<Tag color="red">BLOCK</Tag>
</Option>
<Option value="MASK">
<Tag color="orange">MASK</Tag>
</Option>
<SelectTrigger size="sm" className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
<Badge variant={item.value === "BLOCK" ? "destructive" : "secondary"}>{item.value}</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
),
},
@ -203,13 +215,22 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
size: 180,
cell: ({ row }) => (
<Select
items={SEVERITY_ITEMS}
value={row.original.severity_threshold}
onChange={(value) => onCategoryUpdate(row.original.id, "severity_threshold", value)}
style={{ width: "100%" }}
onValueChange={(value: string | null) =>
value && onCategoryUpdate(row.original.id, "severity_threshold", value)
}
>
<Option value="low">Low</Option>
<Option value="medium">Medium</Option>
<Option value="high">High</Option>
<SelectTrigger size="sm" className="w-full" aria-label="Severity Threshold">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{SEVERITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
@ -218,7 +239,8 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
id: "actions",
size: 80,
cell: ({ row }) => (
<Button icon={<DeleteOutlined />} onClick={() => onCategoryRemove(row.original.id)} size="small">
<Button variant="outline" size="sm" onClick={() => onCategoryRemove(row.original.id)}>
<Trash2 />
Remove
</Button>
),
@ -228,172 +250,120 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
const unselectedCategories = availableCategories.filter(
(cat) => !selectedCategories.some((sel) => sel.category === cat.name),
);
const pendingCategory = availableCategories.find((c) => c.name === selectedCategoryName) ?? null;
return (
<Card
title={
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}
>
<Title level={5} style={{ margin: 0 }}>
Blocked topics
</Title>
<Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>Blocked topics</CardTitle>
<p className="text-xs font-normal text-muted-foreground">
Select topics to block using keyword and semantic analysis
</Text>
</p>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex gap-2">
<Combobox
items={unselectedCategories}
value={pendingCategory}
onValueChange={(category: ContentCategory | null) => setSelectedCategoryName(category?.name ?? "")}
itemToStringLabel={(category: ContentCategory) => category.display_name}
>
<ComboboxInput className="w-full" placeholder="Select a content category" />
<ComboboxContent>
<ComboboxEmpty>No matching categories</ComboboxEmpty>
<ComboboxList>
{(cat: ContentCategory) => (
<ComboboxItem key={cat.name} value={cat}>
<div>
<div className="font-medium">{cat.display_name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{cat.description}</div>
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Button onClick={handleAddCategory} disabled={!selectedCategoryName}>
<Plus />
Add
</Button>
</div>
}
size="small"
>
<div style={{ marginBottom: 16, display: "flex", gap: 8 }}>
<Select
placeholder="Select a content category"
value={selectedCategoryName || undefined}
onChange={setSelectedCategoryName}
style={{ flex: 1 }}
showSearch
optionLabelProp="label"
filterOption={(input, option) =>
(option?.label?.toString().toLowerCase() ?? "").includes(input.toLowerCase())
}
>
{unselectedCategories.map((cat) => (
<Option key={cat.name} value={cat.name} label={cat.display_name}>
<div>
<div style={{ fontWeight: 500 }}>{cat.display_name}</div>
<div style={{ fontSize: "12px", color: "#666", marginTop: "2px" }}>{cat.description}</div>
</div>
</Option>
))}
</Select>
<Button type="primary" onClick={handleAddCategory} disabled={!selectedCategoryName} icon={<PlusOutlined />}>
Add
</Button>
</div>
{/* Preview box - shown when category is selected but not yet added */}
{selectedCategoryName && (
<div
style={{
marginBottom: 16,
padding: "12px",
background: "#f9f9f9",
border: "1px solid #e0e0e0",
borderRadius: "4px",
}}
>
<div style={{ marginBottom: 8, fontWeight: 500, fontSize: "14px" }}>
Preview: {availableCategories.find((c) => c.name === selectedCategoryName)?.display_name}
{categoryFileTypes[selectedCategoryName] && (
<span style={{ marginLeft: 8, fontSize: "12px", color: "#888", fontWeight: 400 }}>
({categoryFileTypes[selectedCategoryName]?.toUpperCase()})
</span>
{/* Preview box - shown when category is selected but not yet added */}
{selectedCategoryName && (
<div className="mb-4 rounded-md border border-border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">
Preview: {availableCategories.find((c) => c.name === selectedCategoryName)?.display_name}
{categoryFileTypes[selectedCategoryName] && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
({categoryFileTypes[selectedCategoryName]?.toUpperCase()})
</span>
)}
</div>
{loadingPreviewYaml ? (
<div className="p-4 text-center text-muted-foreground">Loading content...</div>
) : previewYaml ? (
<pre className="m-0 max-h-[300px] max-w-full overflow-auto rounded-md border border-border bg-background p-3 text-xs leading-relaxed break-words whitespace-pre-wrap">
<code>{previewYaml}</code>
</pre>
) : (
<div className="p-2 text-center text-xs text-muted-foreground">Unable to load category content</div>
)}
</div>
{loadingPreviewYaml ? (
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>Loading content...</div>
) : previewYaml ? (
<pre
style={{
background: "#fff",
padding: "12px",
borderRadius: "4px",
overflow: "auto",
maxHeight: "300px",
maxWidth: "100%",
fontSize: "12px",
lineHeight: "1.5",
margin: 0,
border: "1px solid #e0e0e0",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
<code>{previewYaml}</code>
</pre>
) : (
<div style={{ padding: "8px", textAlign: "center", color: "#888", fontSize: "12px" }}>
Unable to load category content
</div>
)}
</div>
)}
)}
{selectedCategories.length > 0 ? (
<>
<DataTable data={selectedCategories} columns={columns} getRowId={(row) => row.id} size="compact" />
<div style={{ marginTop: 16 }}>
<Collapse
activeKey={expandedYamlCategories}
onChange={(keys) => {
const keyArray = Array.isArray(keys) ? keys : keys ? [keys] : [];
const oldExpanded = new Set(expandedYamlCategories);
// Find newly expanded categories and fetch their YAML
keyArray.forEach((key) => {
const categoryName = key as string;
if (!oldExpanded.has(categoryName) && !categoryYaml[categoryName]) {
fetchCategoryYaml(categoryName);
}
});
setExpandedYamlCategories(keyArray as string[]);
}}
ghost
items={selectedCategories.map((category) => {
{selectedCategories.length > 0 ? (
<>
<DataTable data={selectedCategories} columns={columns} getRowId={(row) => row.id} size="compact" />
<div className="mt-4 space-y-2">
{selectedCategories.map((category) => {
const fileType = categoryFileTypes[category.category] || "yaml";
const fileTypeLabel = fileType.toUpperCase();
const isExpanded = expandedYamlCategories.includes(category.category);
return {
key: category.category,
label: (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<FileTextOutlined />
return (
<Collapsible
key={category.category}
open={isExpanded}
onOpenChange={(open) => {
if (open && !categoryYaml[category.category]) {
fetchCategoryYaml(category.category);
}
setExpandedYamlCategories((prev) =>
open ? [...prev, category.category] : prev.filter((name) => name !== category.category),
);
}}
>
<CollapsibleTrigger className="flex items-center gap-2 text-sm">
<ChevronRight className={`size-4 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
<FileText className="size-4" />
<span>
View {fileTypeLabel} for {category.display_name}
View {fileType.toUpperCase()} for {category.display_name}
</span>
</div>
),
children: loadingYaml[category.category] ? (
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>Loading content...</div>
) : categoryYaml[category.category] ? (
<pre
style={{
background: "#f5f5f5",
padding: "16px",
borderRadius: "4px",
overflow: "auto",
maxHeight: "400px",
fontSize: "12px",
lineHeight: "1.5",
margin: 0,
}}
>
<code>{categoryYaml[category.category]}</code>
</pre>
) : (
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
Content will load when expanded
</div>
),
};
</CollapsibleTrigger>
<CollapsibleContent>
{loadingYaml[category.category] ? (
<div className="p-4 text-center text-muted-foreground">Loading content...</div>
) : categoryYaml[category.category] ? (
<pre className="m-0 max-h-[400px] overflow-auto rounded-md bg-muted p-4 text-xs leading-relaxed">
<code>{categoryYaml[category.category]}</code>
</pre>
) : (
<div className="p-4 text-center text-muted-foreground">Content will load when expanded</div>
)}
</CollapsibleContent>
</Collapsible>
);
})}
/>
</div>
</>
) : (
<div className="rounded-md border border-dashed border-border p-6 text-center text-muted-foreground">
No blocked topics selected. Add topics to detect and block harmful content.
</div>
</>
) : (
<div
style={{
textAlign: "center",
padding: "24px",
color: "#888",
border: "1px dashed #d9d9d9",
borderRadius: "4px",
}}
>
No blocked topics selected. Add topics to detect and block harmful content.
</div>
)}
)}
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders, screen } from "@/../tests/test-utils";
import userEvent from "@testing-library/user-event";
import ContentFilterConfiguration from "./ContentFilterConfiguration";
vi.mock("@/components/networking", () => ({
validateBlockedWordsFile: vi.fn(),
getCategoryYaml: vi.fn(),
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() },
}));
const PREBUILT = [
{ name: "us_ssn", display_name: "US Social Security Number", category: "PII Patterns", description: "d" },
];
describe("ContentFilterConfiguration", () => {
const handlers = {
onPatternAdd: vi.fn(),
onPatternRemove: vi.fn(),
onPatternActionChange: vi.fn(),
onBlockedWordAdd: vi.fn(),
onBlockedWordRemove: vi.fn(),
onBlockedWordUpdate: vi.fn(),
};
const renderConfig = (overrides = {}) =>
renderWithProviders(
<ContentFilterConfiguration
prebuiltPatterns={PREBUILT}
categories={["PII Patterns"]}
selectedPatterns={[]}
blockedWords={[]}
accessToken="test-token"
{...handlers}
{...overrides}
/>,
);
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the pattern and keyword sections", () => {
renderConfig();
expect(screen.getByText("Pattern Detection")).toBeInTheDocument();
expect(
screen.getByText("Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)"),
).toBeInTheDocument();
expect(screen.getByText("Blocked Keywords")).toBeInTheDocument();
expect(screen.getByText("Block or mask specific sensitive terms and phrases")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add prebuilt pattern/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add custom regex/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add keyword/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /upload yaml file/i })).toBeInTheDocument();
});
it("should show the empty states for patterns and keywords", () => {
renderConfig();
expect(screen.getByText("No patterns added.")).toBeInTheDocument();
expect(screen.getByText("No keywords added.")).toBeInTheDocument();
});
it("should open the prebuilt pattern modal", async () => {
const user = userEvent.setup();
renderConfig();
expect(screen.queryByText("Pattern type")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /add prebuilt pattern/i }));
expect(await screen.findByText("Pattern type")).toBeInTheDocument();
});
it("should open the custom regex modal", async () => {
const user = userEvent.setup();
renderConfig();
expect(screen.queryByText("Add custom regex pattern")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /add custom regex/i }));
expect(await screen.findByText("Add custom regex pattern")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., ID-[0-9]{6}")).toBeInTheDocument();
});
it("should open the keyword modal", async () => {
const user = userEvent.setup();
renderConfig();
expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /add keyword/i }));
expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument();
});
it("should list already selected patterns and keywords", () => {
renderConfig({
selectedPatterns: [
{
id: "pattern-1",
type: "prebuilt" as const,
name: "us_ssn",
display_name: "US Social Security Number",
action: "BLOCK" as const,
},
],
blockedWords: [{ id: "word-1", keyword: "secret", action: "MASK" as const, description: "Sensitive" }],
});
expect(screen.getByText("US Social Security Number")).toBeInTheDocument();
expect(screen.getByText("secret")).toBeInTheDocument();
expect(screen.queryByText("No patterns added.")).not.toBeInTheDocument();
expect(screen.queryByText("No keywords added.")).not.toBeInTheDocument();
});
it("should show only the keyword section when the keywords step is requested", () => {
renderConfig({ showStep: "keywords" });
expect(screen.getByText("Blocked Keywords")).toBeInTheDocument();
expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument();
});
it("should show only the pattern section when the patterns step is requested", () => {
renderConfig({ showStep: "patterns" });
expect(screen.getByText("Pattern Detection")).toBeInTheDocument();
expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument();
});
});

View file

@ -1,8 +1,10 @@
import React, { useState } from "react";
import { Typography, Space, Upload, Card, Button } from "antd";
import { PlusOutlined, UploadOutlined } from "@ant-design/icons";
import React, { useRef, useState } from "react";
import { Plus, Upload } from "lucide-react";
import { validateBlockedWordsFile } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import PatternModal from "./PatternModal";
import CustomPatternModal from "./CustomPatternModal";
import KeywordModal from "./KeywordModal";
@ -11,8 +13,6 @@ import KeywordTable from "./KeywordTable";
import ContentCategoryConfiguration from "./ContentCategoryConfiguration";
import CompetitorIntentConfiguration, { CompetitorIntentConfig } from "./CompetitorIntentConfiguration";
const { Title, Text } = Typography;
interface PrebuiltPattern {
name: string;
display_name: string;
@ -115,6 +115,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [newKeywordDescription, setNewKeywordDescription] = useState<string>("");
const [uploadValidating, setUploadValidating] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAddPrebuiltPattern = () => {
if (!selectedPatternName) {
@ -201,6 +202,14 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
return false;
};
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) {
handleFileUpload(file);
}
};
const showPatterns = !showStep || showStep === "patterns";
const showKeywords = !showStep || showStep === "keywords";
const showCategories = !showStep || showStep === "categories";
@ -210,68 +219,78 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
<div className="space-y-6">
{!showStep && (
<div>
<Text type="secondary">
<p className="text-muted-foreground">
Configure patterns, keywords, and content categories to detect and filter sensitive information in requests
and responses.
</Text>
</p>
</div>
)}
{showPatterns && (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Pattern Detection
</Title>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>Pattern Detection</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)
</Text>
</p>
</div>
}
size="small"
>
<div style={{ marginBottom: 16 }}>
<Space>
<Button type="primary" onClick={() => setPatternModalVisible(true)} icon={<PlusOutlined />}>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap gap-2">
<Button onClick={() => setPatternModalVisible(true)}>
<Plus />
Add prebuilt pattern
</Button>
<Button onClick={() => setCustomPatternModalVisible(true)} icon={<PlusOutlined />}>
<Button variant="outline" onClick={() => setCustomPatternModalVisible(true)}>
<Plus />
Add custom regex
</Button>
</Space>
</div>
<PatternTable patterns={selectedPatterns} onActionChange={onPatternActionChange} onRemove={onPatternRemove} />
</div>
<PatternTable
patterns={selectedPatterns}
onActionChange={onPatternActionChange}
onRemove={onPatternRemove}
/>
</CardContent>
</Card>
)}
{showKeywords && (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Blocked Keywords
</Title>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>Blocked Keywords</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
Block or mask specific sensitive terms and phrases
</Text>
</p>
</div>
}
size="small"
>
<div style={{ marginBottom: 16 }}>
<Space>
<Button type="primary" onClick={() => setKeywordModalVisible(true)} icon={<PlusOutlined />}>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap gap-2">
<Button onClick={() => setKeywordModalVisible(true)}>
<Plus />
Add keyword
</Button>
<Upload beforeUpload={handleFileUpload} accept=".yaml,.yml" showUploadList={false}>
<Button icon={<UploadOutlined />} loading={uploadValidating}>
Upload YAML file
</Button>
</Upload>
</Space>
</div>
<KeywordTable keywords={blockedWords} onActionChange={onBlockedWordUpdate} onRemove={onBlockedWordRemove} />
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFileInputChange}
/>
<Button
variant="outline"
disabled={uploadValidating}
aria-busy={uploadValidating}
onClick={() => fileInputRef.current?.click()}
>
{uploadValidating ? <UiLoadingSpinner className="size-4" /> : <Upload />}
Upload YAML file
</Button>
</div>
<KeywordTable keywords={blockedWords} onActionChange={onBlockedWordUpdate} onRemove={onBlockedWordRemove} />
</CardContent>
</Card>
)}

View file

@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders, screen } from "@/../tests/test-utils";
import ContentFilterDisplay from "./ContentFilterDisplay";
const PATTERN = {
id: "pattern-1",
type: "prebuilt" as const,
name: "email",
display_name: "Email address",
action: "BLOCK" as const,
};
const KEYWORD = {
id: "word-1",
keyword: "secret",
action: "MASK" as const,
description: "Sensitive term",
};
const CATEGORY = {
id: "category-1",
category: "self_harm",
display_name: "Self Harm",
action: "BLOCK" as const,
severity_threshold: "high" as const,
};
describe("ContentFilterDisplay", () => {
it("should render nothing when there is no content filter data", () => {
const { container } = renderWithProviders(<ContentFilterDisplay patterns={[]} blockedWords={[]} categories={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("should render the categories section with a configured count", () => {
renderWithProviders(<ContentFilterDisplay patterns={[]} blockedWords={[]} categories={[CATEGORY]} />);
expect(screen.getByText("Content Categories")).toBeInTheDocument();
expect(screen.getByText("1 categories configured")).toBeInTheDocument();
expect(screen.getByText("Self Harm")).toBeInTheDocument();
expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument();
expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument();
});
it("should render the patterns section with a configured count", () => {
renderWithProviders(<ContentFilterDisplay patterns={[PATTERN]} blockedWords={[]} categories={[]} />);
expect(screen.getByText("Pattern Detection")).toBeInTheDocument();
expect(screen.getByText("1 patterns configured")).toBeInTheDocument();
expect(screen.getByText("Email address")).toBeInTheDocument();
expect(screen.queryByText("Content Categories")).not.toBeInTheDocument();
});
it("should render the keywords section with a configured count", () => {
renderWithProviders(<ContentFilterDisplay patterns={[]} blockedWords={[KEYWORD]} categories={[]} />);
expect(screen.getByText("Blocked Keywords")).toBeInTheDocument();
expect(screen.getByText("1 keywords configured")).toBeInTheDocument();
expect(screen.getByText("secret")).toBeInTheDocument();
expect(screen.getByText("Sensitive term")).toBeInTheDocument();
});
it("should render every section when all three kinds of data are present", () => {
renderWithProviders(<ContentFilterDisplay patterns={[PATTERN]} blockedWords={[KEYWORD]} categories={[CATEGORY]} />);
expect(screen.getByText("Content Categories")).toBeInTheDocument();
expect(screen.getByText("Pattern Detection")).toBeInTheDocument();
expect(screen.getByText("Blocked Keywords")).toBeInTheDocument();
});
it("should render category severity and action as static text in read-only mode", () => {
renderWithProviders(
<ContentFilterDisplay patterns={[PATTERN]} blockedWords={[KEYWORD]} categories={[CATEGORY]} readOnly={true} />,
);
expect(screen.getByText("HIGH")).toBeInTheDocument();
expect(screen.getByText("BLOCK")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(2);
});
it("should render category severity and action as editable controls when not read-only", () => {
renderWithProviders(
<ContentFilterDisplay patterns={[PATTERN]} blockedWords={[KEYWORD]} categories={[CATEGORY]} readOnly={false} />,
);
expect(screen.queryByText("HIGH")).not.toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(3);
});
});

View file

@ -1,5 +1,6 @@
import React from "react";
import { Card, Text, Badge } from "@tremor/react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import PatternTable from "./PatternTable";
import KeywordTable from "./KeywordTable";
import CategoryTable from "./CategoryTable";
@ -66,45 +67,51 @@ const ContentFilterDisplay: React.FC<ContentFilterDisplayProps> = ({
<>
{categories.length > 0 && (
<Card className="mt-6">
<div className="flex justify-between items-center mb-4">
<Text className="text-lg font-semibold">Content Categories</Text>
<Badge color="blue">{categories.length} categories configured</Badge>
</div>
<CategoryTable
categories={categories}
onActionChange={readOnly ? undefined : onCategoryActionChange}
onSeverityChange={readOnly ? undefined : onCategorySeverityChange}
onRemove={readOnly ? undefined : onCategoryRemove}
readOnly={readOnly}
/>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-lg font-semibold">Content Categories</p>
<Badge variant="secondary">{categories.length} categories configured</Badge>
</div>
<CategoryTable
categories={categories}
onActionChange={readOnly ? undefined : onCategoryActionChange}
onSeverityChange={readOnly ? undefined : onCategorySeverityChange}
onRemove={readOnly ? undefined : onCategoryRemove}
readOnly={readOnly}
/>
</CardContent>
</Card>
)}
{patterns.length > 0 && (
<Card className="mt-6">
<div className="flex justify-between items-center mb-4">
<Text className="text-lg font-semibold">Pattern Detection</Text>
<Badge color="blue">{patterns.length} patterns configured</Badge>
</div>
<PatternTable
patterns={patterns}
onActionChange={readOnly ? noOp : onPatternActionChange || noOp}
onRemove={readOnly ? noOp : onPatternRemove || noOp}
/>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-lg font-semibold">Pattern Detection</p>
<Badge variant="secondary">{patterns.length} patterns configured</Badge>
</div>
<PatternTable
patterns={patterns}
onActionChange={readOnly ? noOp : onPatternActionChange || noOp}
onRemove={readOnly ? noOp : onPatternRemove || noOp}
/>
</CardContent>
</Card>
)}
{blockedWords.length > 0 && (
<Card className="mt-6">
<div className="flex justify-between items-center mb-4">
<Text className="text-lg font-semibold">Blocked Keywords</Text>
<Badge color="blue">{blockedWords.length} keywords configured</Badge>
</div>
<KeywordTable
keywords={blockedWords}
onActionChange={readOnly ? noOp : onBlockedWordUpdate || noOp}
onRemove={readOnly ? noOp : onBlockedWordRemove || noOp}
/>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-lg font-semibold">Blocked Keywords</p>
<Badge variant="secondary">{blockedWords.length} keywords configured</Badge>
</div>
<KeywordTable
keywords={blockedWords}
onActionChange={readOnly ? noOp : onBlockedWordUpdate || noOp}
onRemove={readOnly ? noOp : onBlockedWordRemove || noOp}
/>
</CardContent>
</Card>
)}
</>

View file

@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ContentFilterManager, { formatContentFilterDataForAPI } from "./ContentFilterManager";
import React from "react";
const CONTENT_FILTER_GUARDRAIL_DATA = {
litellm_params: {
@ -85,18 +84,7 @@ vi.mock("./ContentFilterDisplay", () => ({
),
}));
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
Divider: ({ children }: { children: React.ReactNode }) => <div data-testid="divider">{children}</div>,
Alert: ({ message, type }: { message: React.ReactNode; type: string }) => (
<div data-testid="unsaved-alert" data-type={type}>
{message}
</div>
),
};
});
const UNSAVED_CHANGES_TEXT = /You have unsaved changes to patterns or keywords/;
describe("ContentFilterManager", () => {
beforeEach(() => {
@ -117,7 +105,7 @@ describe("ContentFilterManager", () => {
expect(screen.getByTestId("content-filter-config")).toBeInTheDocument();
});
expect(screen.getByTestId("divider")).toHaveTextContent("Content Filter Configuration");
expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument();
});
it("should return null when guardrail is not litellm_content_filter", () => {
@ -275,15 +263,15 @@ describe("ContentFilterManager", () => {
expect(screen.getByTestId("content-filter-config")).toBeInTheDocument();
});
expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument();
expect(screen.queryByText(UNSAVED_CHANGES_TEXT)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /add pattern/i }));
await waitFor(() => {
expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument();
expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toBeInTheDocument();
});
expect(screen.getByTestId("unsaved-alert")).toHaveTextContent(/unsaved changes.*Save Changes/i);
expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toHaveTextContent(/Save Changes/i);
});
it("should call onDataChange when patterns or keywords change", async () => {
@ -371,7 +359,7 @@ describe("ContentFilterManager", () => {
);
await waitFor(() => {
expect(screen.getByTestId("divider")).toBeInTheDocument();
expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument();
});
expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument();

View file

@ -1,11 +1,11 @@
import { Alert, Divider, Typography } from "antd";
import { TriangleAlert } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { Separator } from "@/components/ui/separator";
import ContentFilterConfiguration from "./ContentFilterConfiguration";
import ContentFilterDisplay from "./ContentFilterDisplay";
import type { CompetitorIntentConfig } from "./CompetitorIntentConfiguration";
const { Text } = Typography;
interface Pattern {
id: string;
type: "prebuilt" | "custom";
@ -233,19 +233,17 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
// Edit mode
return (
<>
<Divider orientation="left">Content Filter Configuration</Divider>
<div className="my-6 flex items-center gap-4">
<span className="shrink-0 font-medium">Content Filter Configuration</span>
<Separator className="flex-1" />
</div>
{hasUnsavedChanges && (
<Alert
type="warning"
showIcon
className="mb-4"
message={
<Text>
You have unsaved changes to patterns or keywords. Remember to click &quot;Save Changes&quot; at the
bottom.
</Text>
}
/>
<Alert variant="warning" className="mb-4">
<TriangleAlert />
<AlertDescription>
You have unsaved changes to patterns or keywords. Remember to click &quot;Save Changes&quot; at the bottom.
</AlertDescription>
</Alert>
)}
<div className="mb-6">
{guardrailSettings && guardrailSettings.content_filter_settings && (

View file

@ -130,4 +130,77 @@ describe("content filter tables", () => {
expect(onCategoryRemove).toHaveBeenCalledWith("category-1");
});
it("should report a pattern action change", async () => {
const onActionChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<PatternTable
patterns={[{ id: "pattern-1", type: "prebuilt", name: "email", action: "BLOCK" }]}
onActionChange={onActionChange}
onRemove={vi.fn()}
/>,
);
await user.click(screen.getByRole("combobox"));
const maskOptions = await screen.findAllByText("Mask");
await user.click(maskOptions[maskOptions.length - 1]);
expect(onActionChange).toHaveBeenCalledWith("pattern-1", "MASK");
});
it("should report a keyword action change", async () => {
const onActionChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<KeywordTable
keywords={[{ id: "keyword-1", keyword: "secret", action: "BLOCK" }]}
onActionChange={onActionChange}
onRemove={vi.fn()}
/>,
);
await user.click(screen.getByRole("combobox"));
const maskOptions = await screen.findAllByText("Mask");
await user.click(maskOptions[maskOptions.length - 1]);
expect(onActionChange).toHaveBeenCalledWith("keyword-1", "action", "MASK");
});
it("should report category severity and action changes", async () => {
const onSeverityChange = vi.fn();
const onActionChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<CategoryTable
categories={[
{
id: "category-1",
category: "self_harm",
display_name: "Self Harm",
action: "BLOCK",
severity_threshold: "high",
},
]}
onActionChange={onActionChange}
onSeverityChange={onSeverityChange}
onRemove={vi.fn()}
/>,
);
await user.click(screen.getAllByRole("combobox")[0]);
const lowOptions = await screen.findAllByText("Low");
await user.click(lowOptions[lowOptions.length - 1]);
expect(onSeverityChange).toHaveBeenCalledWith("category-1", "low");
await user.click(screen.getAllByRole("combobox")[1]);
const maskOptions = await screen.findAllByText("Mask");
await user.click(maskOptions[maskOptions.length - 1]);
expect(onActionChange).toHaveBeenCalledWith("category-1", "MASK");
});
});

View file

@ -1,8 +1,10 @@
import React from "react";
import { Typography, Select, Modal, Space, Button, Input } from "antd";
const { Text } = Typography;
const { Option } = Select;
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
import { ABOVE_ANTD_MODAL } from "./dialog_layering";
interface CustomPatternModalProps {
visible: boolean;
@ -28,50 +30,66 @@ const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
onCancel,
}) => {
return (
<Modal title="Add custom regex pattern" open={visible} onCancel={onCancel} footer={null} width={800}>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong>Pattern name</Text>
<Input
placeholder="e.g., internal_id, employee_code"
value={patternName}
onChange={(e) => onNameChange(e.target.value)}
style={{ marginTop: 8 }}
/>
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className={`max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px] ${ABOVE_ANTD_MODAL}`}>
<DialogHeader>
<DialogTitle>Add custom regex pattern</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div>
<p className="font-semibold">Pattern name</p>
<Input
className="mt-2"
placeholder="e.g., internal_id, employee_code"
value={patternName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<div>
<p className="font-semibold">Regex pattern</p>
<Input
className="mt-2"
placeholder="e.g., ID-[0-9]{6}"
value={patternRegex}
onChange={(e) => onRegexChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Enter a valid regular expression to match sensitive data</p>
</div>
<div>
<p className="font-semibold">Action</p>
<p className="mt-1 mb-2 text-muted-foreground">
Choose what action the guardrail should take when this pattern is detected
</p>
<Select
items={ACTION_ITEMS}
value={patternAction}
onValueChange={(value: string | null) => value && onActionChange(value as "BLOCK" | "MASK")}
>
<SelectTrigger className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Text strong>Regex pattern</Text>
<Input
placeholder="e.g., ID-[0-9]{6}"
value={patternRegex}
onChange={(e) => onRegexChange(e.target.value)}
style={{ marginTop: 8 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
Enter a valid regular expression to match sensitive data
</Text>
</div>
<div>
<Text strong>Action</Text>
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
Choose what action the guardrail should take when this pattern is detected
</Text>
<Select value={patternAction} onChange={onActionChange} style={{ width: "100%" }}>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
</div>
</Space>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" onClick={onAdd}>
Add
</Button>
</div>
</Modal>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAdd}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import KeywordModal from "./KeywordModal";
describe("KeywordModal", () => {
const handlers = {
onKeywordChange: vi.fn(),
onActionChange: vi.fn(),
onDescriptionChange: vi.fn(),
onAdd: vi.fn(),
onCancel: vi.fn(),
};
const renderModal = (overrides: Partial<React.ComponentProps<typeof KeywordModal>> = {}) =>
render(<KeywordModal visible keyword="" action="BLOCK" description="" {...handlers} {...overrides} />);
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the keyword, action and description fields", async () => {
renderModal();
expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Explain why this keyword is sensitive")).toBeInTheDocument();
expect(screen.getByText("Description (optional)")).toBeInTheDocument();
expect(
screen.getByText("Choose what action the guardrail should take when this keyword is detected"),
).toBeInTheDocument();
});
it("should report keyword edits", async () => {
const user = userEvent.setup();
renderModal();
await user.type(await screen.findByPlaceholderText("Enter sensitive keyword or phrase"), "s");
expect(handlers.onKeywordChange).toHaveBeenCalledWith("s");
});
it("should report description edits", async () => {
const user = userEvent.setup();
renderModal();
await user.type(await screen.findByPlaceholderText("Explain why this keyword is sensitive"), "x");
expect(handlers.onDescriptionChange).toHaveBeenCalledWith("x");
});
it("should report the chosen action", async () => {
const user = userEvent.setup();
renderModal();
await user.click(await screen.findByRole("combobox"));
const maskOptions = await screen.findAllByText("Mask");
await user.click(maskOptions[maskOptions.length - 1]);
expect(handlers.onActionChange).toHaveBeenCalled();
expect(handlers.onActionChange.mock.calls[0][0]).toBe("MASK");
});
it("should show the current keyword and description values", async () => {
renderModal({ keyword: "secret", description: "sensitive term" });
expect(await screen.findByDisplayValue("secret")).toBeInTheDocument();
expect(screen.getByDisplayValue("sensitive term")).toBeInTheDocument();
});
it("should add and cancel through the footer buttons", async () => {
const user = userEvent.setup();
renderModal();
await user.click(await screen.findByRole("button", { name: "Add" }));
expect(handlers.onAdd).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(handlers.onCancel).toHaveBeenCalledTimes(1);
});
it("should not render its content when not visible", () => {
renderModal({ visible: false });
expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument();
});
});

View file

@ -1,8 +1,11 @@
import React from "react";
import { Typography, Select, Modal, Space, Button, Input } from "antd";
const { Text } = Typography;
const { Option } = Select;
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ACTION_ITEMS } from "./action_options";
import { ABOVE_ANTD_MODAL } from "./dialog_layering";
interface KeywordModalProps {
visible: boolean;
@ -28,48 +31,66 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
onCancel,
}) => {
return (
<Modal title="Add blocked keyword" open={visible} onCancel={onCancel} footer={null} width={800}>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong>Keyword</Text>
<Input
placeholder="Enter sensitive keyword or phrase"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
style={{ marginTop: 8 }}
/>
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className={`max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px] ${ABOVE_ANTD_MODAL}`}>
<DialogHeader>
<DialogTitle>Add blocked keyword</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div>
<p className="font-semibold">Keyword</p>
<Input
className="mt-2"
placeholder="Enter sensitive keyword or phrase"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
/>
</div>
<div>
<p className="font-semibold">Action</p>
<p className="mt-1 mb-2 text-muted-foreground">
Choose what action the guardrail should take when this keyword is detected
</p>
<Select
items={ACTION_ITEMS}
value={action}
onValueChange={(value: string | null) => value && onActionChange(value as "BLOCK" | "MASK")}
>
<SelectTrigger className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="font-semibold">Description (optional)</p>
<Textarea
className="mt-2 field-sizing-fixed"
placeholder="Explain why this keyword is sensitive"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
rows={3}
/>
</div>
</div>
<div>
<Text strong>Action</Text>
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
Choose what action the guardrail should take when this keyword is detected
</Text>
<Select value={action} onChange={onActionChange} style={{ width: "100%" }}>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
</div>
<div>
<Text strong>Description (optional)</Text>
<Input.TextArea
placeholder="Explain why this keyword is sensitive"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
rows={3}
style={{ marginTop: 8 }}
/>
</div>
</Space>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" onClick={onAdd}>
Add
</Button>
</div>
</Modal>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAdd}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,10 +1,10 @@
import { DeleteOutlined } from "@ant-design/icons";
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Button, Select } from "antd";
import React from "react";
import { DataTable } from "@/components/shared/DataTable";
const { Option } = Select;
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
interface BlockedWord {
id: string;
@ -31,13 +31,20 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
size: 150,
cell: ({ row }) => (
<Select
items={ACTION_ITEMS}
value={row.original.action}
onChange={(value) => onActionChange(row.original.id, "action", value)}
style={{ width: 120 }}
size="small"
onValueChange={(value: string | null) => value && onActionChange(row.original.id, "action", value)}
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
@ -51,7 +58,8 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
id: "actions",
size: 100,
cell: ({ row }) => (
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.original.id)}>
<Button variant="ghost" size="sm" onClick={() => onRemove(row.original.id)}>
<Trash2 />
Delete
</Button>
),
@ -59,7 +67,7 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
];
if (keywords.length === 0) {
return <div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>No keywords added.</div>;
return <div className="py-10 text-center text-muted-foreground">No keywords added.</div>;
}
return <DataTable data={keywords} columns={columns} getRowId={(row) => row.id} size="compact" />;

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PatternModal from "./PatternModal";
@ -10,25 +10,25 @@ describe("PatternModal", () => {
const mockOnActionChange = vi.fn();
const mockPrebuiltPatterns = [
{ name: "us_ssn", category: "PII Patterns", description: "US Social Security Number" },
{ name: "email", category: "PII Patterns", description: "Email addresses" },
{ name: "visa", category: "Financial Patterns", description: "Visa credit card numbers" },
{ name: "aws_access_key", category: "Credential Patterns", description: "AWS Access Keys" },
{
name: "us_ssn",
display_name: "US Social Security Number",
category: "PII Patterns",
description: "US Social Security Number",
},
{ name: "email", display_name: "Email address", category: "PII Patterns", description: "Email addresses" },
{ name: "visa", display_name: "Visa card", category: "Financial Patterns", description: "Visa credit cards" },
{
name: "aws_access_key",
display_name: "AWS access key",
category: "Credential Patterns",
description: "AWS Access Keys",
},
];
const mockCategories = ["PII Patterns", "Financial Patterns", "Credential Patterns"];
beforeEach(() => {
vi.clearAllMocks();
});
it("should show dropdown with prebuilt pattern options grouped by category", async () => {
/**
* Tests that the modal displays a dropdown with prebuilt patterns
* organized by category. This verifies the pattern selection UI is working.
*/
const user = userEvent.setup();
const renderModal = () =>
render(
<PatternModal
visible={true}
@ -43,49 +43,103 @@ describe("PatternModal", () => {
/>,
);
// Wait for modal to be visible
await waitFor(() => {
expect(screen.getByText("Add prebuilt pattern")).toBeInTheDocument();
});
beforeEach(() => {
vi.clearAllMocks();
});
// Find the pattern type dropdown by looking for the first combobox input
const comboboxes = screen.getAllByRole("combobox");
const dropdown = comboboxes[0]; // First combobox is the pattern selector
expect(dropdown).toBeInTheDocument();
it("should show prebuilt pattern options grouped by category and report the picked pattern", async () => {
const user = userEvent.setup();
renderModal();
// Click to open the dropdown
await user.click(dropdown);
expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument();
// Verify that pattern options are available in the dropdown
// Ant Design renders Select options in a portal, so we need to query the whole document
await waitFor(() => {
const options = document.querySelectorAll(".ant-select-item-option");
expect(options.length).toBeGreaterThan(0);
});
await user.click(screen.getAllByRole("combobox")[0]);
// Verify categories are shown as group labels
await waitFor(() => {
expect(document.body).toHaveTextContent("PII Patterns");
expect(document.body).toHaveTextContent("Financial Patterns");
expect(document.body).toHaveTextContent("Credential Patterns");
});
expect(await screen.findByText("PII Patterns")).toBeInTheDocument();
expect(screen.getByText("Financial Patterns")).toBeInTheDocument();
expect(screen.getByText("Credential Patterns")).toBeInTheDocument();
// Verify pattern options are available
expect(document.body).toHaveTextContent("us_ssn");
expect(document.body).toHaveTextContent("email");
expect(document.body).toHaveTextContent("visa");
expect(document.body).toHaveTextContent("aws_access_key");
expect(screen.getByText("Email address")).toBeInTheDocument();
expect(screen.getByText("Visa card")).toBeInTheDocument();
expect(screen.getByText("AWS access key")).toBeInTheDocument();
// Select a pattern by clicking on its option element
const ssnOption = Array.from(document.querySelectorAll(".ant-select-item-option")).find(
(el) => el.textContent === "us_ssn",
) as HTMLElement;
await user.click(ssnOption);
const ssnOptions = await screen.findAllByText("US Social Security Number");
await user.click(ssnOptions[ssnOptions.length - 1]);
// Verify the change handler was called with the pattern name
// Note: Ant Design Select calls onChange with (value, option), so we check if it was called
expect(mockOnPatternNameChange).toHaveBeenCalled();
const callArgs = mockOnPatternNameChange.mock.calls[0];
expect(callArgs[0]).toBe("us_ssn");
expect(mockOnPatternNameChange.mock.calls[0][0]).toBe("us_ssn");
});
it("should narrow the pattern options to the typed search text", async () => {
const user = userEvent.setup();
renderModal();
expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument();
await user.click(screen.getAllByRole("combobox")[0]);
await user.keyboard("visa");
expect(await screen.findByText("Visa card")).toBeInTheDocument();
expect(screen.queryByText("Email address")).not.toBeInTheDocument();
expect(screen.queryByText("AWS access key")).not.toBeInTheDocument();
});
it("should match the internal pattern name when it is absent from the display name", async () => {
const user = userEvent.setup();
renderModal();
expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument();
await user.click(screen.getAllByRole("combobox")[0]);
await user.keyboard("ssn");
expect(await screen.findByText("US Social Security Number")).toBeInTheDocument();
expect(screen.queryByText("Email address")).not.toBeInTheDocument();
expect(screen.queryByText("Visa card")).not.toBeInTheDocument();
});
it("should report the chosen action", async () => {
const user = userEvent.setup();
renderModal();
expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument();
await user.click(screen.getAllByRole("combobox")[1]);
const maskOptions = await screen.findAllByText("Mask");
await user.click(maskOptions[maskOptions.length - 1]);
expect(mockOnActionChange).toHaveBeenCalled();
expect(mockOnActionChange.mock.calls[0][0]).toBe("MASK");
});
it("should add and cancel through the footer buttons", async () => {
const user = userEvent.setup();
renderModal();
expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add" }));
expect(mockOnAdd).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it("should not render its content when not visible", () => {
render(
<PatternModal
visible={false}
prebuiltPatterns={mockPrebuiltPatterns}
categories={mockCategories}
selectedPatternName=""
patternAction="BLOCK"
onPatternNameChange={mockOnPatternNameChange}
onActionChange={mockOnActionChange}
onAdd={mockOnAdd}
onCancel={mockOnCancel}
/>,
);
expect(screen.queryByText("Add prebuilt pattern")).not.toBeInTheDocument();
});
});

View file

@ -1,8 +1,20 @@
import React from "react";
import { Typography, Select, Modal, Space, Button } from "antd";
const { Text } = Typography;
const { Option } = Select;
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
import { ABOVE_ANTD_MODAL } from "./dialog_layering";
interface PrebuiltPattern {
name: string;
@ -11,6 +23,16 @@ interface PrebuiltPattern {
description: string;
}
interface PatternGroup {
category: string;
items: PrebuiltPattern[];
}
const matchesPatternQuery = (pattern: PrebuiltPattern, query: string) => {
const needle = query.toLowerCase();
return pattern.display_name.toLowerCase().includes(needle) || pattern.name.toLowerCase().includes(needle);
};
interface PatternModalProps {
visible: boolean;
prebuiltPatterns: PrebuiltPattern[];
@ -34,64 +56,84 @@ const PatternModal: React.FC<PatternModalProps> = ({
onAdd,
onCancel,
}) => {
const selectedPattern = prebuiltPatterns.find((pattern) => pattern.name === selectedPatternName) ?? null;
const patternGroups = categories
.map((category) => ({
category,
items: prebuiltPatterns.filter((pattern) => pattern.category === category),
}))
.filter((group) => group.items.length > 0);
return (
<Modal title="Add prebuilt pattern" open={visible} onCancel={onCancel} footer={null} width={800}>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong>Pattern type</Text>
<Select
placeholder="Choose pattern type"
value={selectedPatternName}
onChange={onPatternNameChange}
style={{ width: "100%", marginTop: 8 }}
showSearch
filterOption={(input, option) => {
const pattern = prebuiltPatterns.find((p) => p.name === option?.value);
if (pattern) {
return (
pattern.display_name.toLowerCase().includes(input.toLowerCase()) ||
pattern.name.toLowerCase().includes(input.toLowerCase())
);
}
return false;
}}
>
{categories.map((category) => {
const categoryPatterns = prebuiltPatterns.filter((p) => p.category === category);
if (categoryPatterns.length === 0) return null;
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className={`max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px] ${ABOVE_ANTD_MODAL}`}>
<DialogHeader>
<DialogTitle>Add prebuilt pattern</DialogTitle>
</DialogHeader>
return (
<Select.OptGroup key={category} label={category}>
{categoryPatterns.map((pattern) => (
<Option key={pattern.name} value={pattern.name}>
{pattern.display_name}
</Option>
))}
</Select.OptGroup>
);
})}
</Select>
<div className="space-y-6">
<div>
<p className="font-semibold">Pattern type</p>
<Combobox
items={patternGroups}
value={selectedPattern}
onValueChange={(pattern: PrebuiltPattern | null) => pattern && onPatternNameChange(pattern.name)}
itemToStringLabel={(pattern: PrebuiltPattern) => pattern.display_name}
filter={matchesPatternQuery}
>
<ComboboxInput className="mt-2 w-full" placeholder="Choose pattern type" />
<ComboboxContent>
<ComboboxEmpty>No matching patterns</ComboboxEmpty>
<ComboboxList>
{(group: PatternGroup) => (
<ComboboxGroup key={group.category} items={group.items}>
<ComboboxLabel>{group.category}</ComboboxLabel>
<ComboboxCollection>
{(pattern: PrebuiltPattern) => (
<ComboboxItem key={pattern.name} value={pattern}>
{pattern.display_name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div>
<p className="font-semibold">Action</p>
<p className="mt-1 mb-2 text-muted-foreground">
Choose what action the guardrail should take when this pattern is detected
</p>
<Select
items={ACTION_ITEMS}
value={patternAction}
onValueChange={(value: string | null) => value && onActionChange(value as "BLOCK" | "MASK")}
>
<SelectTrigger className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Text strong>Action</Text>
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
Choose what action the guardrail should take when this pattern is detected
</Text>
<Select value={patternAction} onChange={onActionChange} style={{ width: "100%" }}>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
</div>
</Space>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" onClick={onAdd}>
Add
</Button>
</div>
</Modal>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAdd}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,11 +1,11 @@
import React from "react";
import { Typography, Select, Tag, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/shared/DataTable";
const { Text } = Typography;
const { Option } = Select;
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
interface Pattern {
id: string;
@ -28,11 +28,7 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
header: "Type",
accessorKey: "type",
size: 100,
cell: ({ row }) => (
<Tag color={row.original.type === "prebuilt" ? "blue" : "green"}>
{row.original.type === "prebuilt" ? "Prebuilt" : "Custom"}
</Tag>
),
cell: ({ row }) => <Badge variant="secondary">{row.original.type === "prebuilt" ? "Prebuilt" : "Custom"}</Badge>,
},
{
header: "Pattern name",
@ -44,9 +40,7 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
accessorKey: "pattern",
cell: ({ row }) =>
row.original.pattern ? (
<Text code style={{ fontSize: 12 }}>
{row.original.pattern.substring(0, 40)}...
</Text>
<code className="rounded-sm bg-muted px-1 py-0.5 text-xs">{row.original.pattern.substring(0, 40)}...</code>
) : (
"-"
),
@ -57,13 +51,20 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
size: 150,
cell: ({ row }) => (
<Select
items={ACTION_ITEMS}
value={row.original.action}
onChange={(value) => onActionChange(row.original.id, value as "BLOCK" | "MASK")}
style={{ width: 120 }}
size="small"
onValueChange={(value: string | null) => value && onActionChange(row.original.id, value as "BLOCK" | "MASK")}
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
@ -72,7 +73,8 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
id: "actions",
size: 100,
cell: ({ row }) => (
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.original.id)}>
<Button variant="ghost" size="sm" onClick={() => onRemove(row.original.id)}>
<Trash2 />
Delete
</Button>
),
@ -80,7 +82,7 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
];
if (patterns.length === 0) {
return <div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>No patterns added.</div>;
return <div className="py-10 text-center text-muted-foreground">No patterns added.</div>;
}
return <DataTable data={patterns} columns={columns} getRowId={(row) => row.id} size="compact" />;

View file

@ -0,0 +1,10 @@
export const ACTION_ITEMS = [
{ value: "BLOCK", label: "Block" },
{ value: "MASK", label: "Mask" },
] as const;
export const SEVERITY_ITEMS = [
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
] as const;

View file

@ -0,0 +1 @@
export const ABOVE_ANTD_MODAL = "z-[1100]";

View file

@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import CustomCodeModal from "./CustomCodeModal";
import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking";
vi.mock("@/components/networking", () => ({
createGuardrailCall: vi.fn(),
updateGuardrailCall: vi.fn(),
testCustomCodeGuardrail: vi.fn(),
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() },
}));
const mockCreate = vi.mocked(createGuardrailCall);
const mockUpdate = vi.mocked(updateGuardrailCall);
const mockTest = vi.mocked(testCustomCodeGuardrail);
describe("CustomCodeModal", () => {
const onClose = vi.fn();
const onSuccess = vi.fn();
const renderModal = (overrides = {}) =>
render(<CustomCodeModal visible onClose={onClose} onSuccess={onSuccess} accessToken="test-token" {...overrides} />);
beforeEach(() => {
vi.clearAllMocks();
mockCreate.mockResolvedValue({} as never);
mockUpdate.mockResolvedValue({} as never);
});
it("should render the create heading and the editor scaffolding", async () => {
renderModal();
expect(await screen.findByText("Create Custom Guardrail")).toBeInTheDocument();
expect(screen.getByText("Define custom logic using Python-like syntax")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., block-pii-custom")).toBeInTheDocument();
expect(screen.getByText("Guardrail Name")).toBeInTheDocument();
expect(screen.getByText("Mode (can select multiple)")).toBeInTheDocument();
expect(screen.getByText("Available Primitives")).toBeInTheDocument();
expect(screen.getByText("Python Logic")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save guardrail/i })).toBeInTheDocument();
});
it("should seed the editor with the empty template", async () => {
renderModal();
const editor = await screen.findByDisplayValue(/async def apply_guardrail/);
expect(editor).toBeInTheDocument();
});
it("should not render its content when not visible", () => {
renderModal({ visible: false });
expect(screen.queryByText("Create Custom Guardrail")).not.toBeInTheDocument();
});
it("should render the edit heading and existing values in edit mode", async () => {
renderModal({
editData: {
guardrail_id: "g-1",
guardrail_name: "existing-guardrail",
litellm_params: { mode: "post_call", default_on: true, custom_code: "def apply_guardrail(): pass" },
},
});
expect(await screen.findByText("Edit Custom Guardrail")).toBeInTheDocument();
expect(screen.getByDisplayValue("existing-guardrail")).toBeInTheDocument();
expect(screen.getByDisplayValue("def apply_guardrail(): pass")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /update guardrail/i })).toBeInTheDocument();
});
it("should keep save disabled until a guardrail name is entered", async () => {
const user = userEvent.setup();
renderModal();
await user.click(await screen.findByRole("button", { name: /save guardrail/i }));
expect(mockCreate).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText("e.g., block-pii-custom"), "my-guardrail");
await user.click(screen.getByRole("button", { name: /save guardrail/i }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledTimes(1);
});
});
it("should create the guardrail with the entered name, mode and code", async () => {
const user = userEvent.setup();
renderModal();
await user.type(await screen.findByPlaceholderText("e.g., block-pii-custom"), "block-pii");
await user.click(screen.getByRole("button", { name: /save guardrail/i }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalled();
});
const [token, payload] = mockCreate.mock.calls[0] as [string, Record<string, never>];
expect(token).toBe("test-token");
expect(payload).toMatchObject({
guardrail_name: "block-pii",
litellm_params: { guardrail: "custom_code", mode: ["pre_call"], default_on: false },
});
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
it("should switch the editor contents when a template is chosen", async () => {
const user = userEvent.setup();
renderModal();
expect(await screen.findByDisplayValue(/async def apply_guardrail/)).toBeInTheDocument();
const comboboxes = screen.getAllByRole("combobox");
await user.click(comboboxes[comboboxes.length - 1]);
const options = await screen.findAllByText("Block SSN");
await user.click(options[options.length - 1]);
expect(await screen.findByDisplayValue(/SSN detected/)).toBeInTheDocument();
});
it("should narrow the mode options to the typed search text", async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getAllByRole("combobox")[0]);
await user.keyboard("mcp");
expect(await screen.findByText("pre_mcp_call (Before MCP Tool Call)")).toBeInTheDocument();
expect(screen.queryByText("logging_only")).not.toBeInTheDocument();
});
it("should expand the test section and run a test against the backend", async () => {
const user = userEvent.setup();
mockTest.mockResolvedValue({ success: true, result: { action: "allow" } } as never);
renderModal();
await user.click(await screen.findByText("Test Your Guardrail"));
const runButton = await screen.findByRole("button", { name: /run test/i });
await user.click(runButton);
await waitFor(() => {
expect(mockTest).toHaveBeenCalled();
});
expect(await screen.findByText("Allowed")).toBeInTheDocument();
});
it("should surface a backend test error", async () => {
const user = userEvent.setup();
mockTest.mockResolvedValue({ success: false, error: "boom", error_type: "SyntaxError" } as never);
renderModal();
await user.click(await screen.findByText("Test Your Guardrail"));
await user.click(await screen.findByRole("button", { name: /run test/i }));
expect(await screen.findByText("boom")).toBeInTheDocument();
expect(screen.getByText("[SyntaxError]")).toBeInTheDocument();
});
it("should cancel through the footer button", async () => {
const user = userEvent.setup();
renderModal();
await user.click(await screen.findByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,21 +1,34 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, Select, Switch, Collapse, Input, Divider } from "antd";
import { Button, TextInput } from "@tremor/react";
import {
CodeOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
CaretRightOutlined,
SaveOutlined,
UsergroupAddOutlined,
ExportOutlined,
} from "@ant-design/icons";
import { CheckCircle2, ChevronRight, Code, ExternalLink, PlayCircle, Save, Users, XCircle } from "lucide-react";
import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
const { Panel } = Collapse;
const { TextArea } = Input;
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
// Code templates
const CODE_TEMPLATES = {
@ -144,6 +157,17 @@ const MODE_OPTIONS = [
{ value: "during_mcp_call", label: "during_mcp_call (During MCP Tool Call)" },
];
const TEMPLATE_ITEMS = Object.entries(CODE_TEMPLATES).map(([key, template]) => ({
value: key,
label: template.name,
}));
type ModeOption = (typeof MODE_OPTIONS)[number];
const MODE_OPTION_BY_VALUE: Record<string, ModeOption> = Object.fromEntries(
MODE_OPTIONS.map((option) => [option.value, option]),
);
// Data for editing an existing guardrail
export interface EditGuardrailData {
guardrail_id: string;
@ -470,105 +494,104 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
};
const lineCount = code.split("\n").length;
const selectedModeOptions = mode.map((value) => MODE_OPTION_BY_VALUE[value]).filter(Boolean);
return (
<Modal
open={visible}
onCancel={onClose}
footer={null}
width={1400}
className="custom-code-modal"
closable={true}
destroyOnClose
>
<div className="flex flex-col h-[80vh]">
{/* Header */}
<div className="pb-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1400px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
{isEditMode ? "Edit Custom Guardrail" : "Create Custom Guardrail"}
</h2>
<p className="text-sm text-gray-500 mt-1">Define custom logic using Python-like syntax</p>
</div>
</DialogTitle>
<DialogDescription>Define custom logic using Python-like syntax</DialogDescription>
</DialogHeader>
{/* Top Controls */}
<div className="flex items-center gap-4 py-4 border-b border-gray-100">
<div className="flex-1 max-w-[200px]">
<label className="block text-xs font-medium text-gray-600 mb-1">Guardrail Name</label>
<TextInput value={guardrailName} onValueChange={setGuardrailName} placeholder="e.g., block-pii-custom" />
</div>
<div className="w-[280px]">
<label className="block text-xs font-medium text-gray-600 mb-1">Mode (can select multiple)</label>
<Select
mode="multiple"
value={mode}
onChange={setMode}
options={MODE_OPTIONS}
className="w-full"
size="middle"
placeholder="Select modes"
<div className="flex items-center gap-4 border-b border-border py-4">
<div className="max-w-[200px] flex-1">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Guardrail Name</label>
<Input
value={guardrailName}
onChange={(e) => setGuardrailName(e.target.value)}
placeholder="e.g., block-pii-custom"
/>
</div>
<div className="w-[180px]">
<label className="block text-xs font-medium text-gray-600 mb-1">Template</label>
<Select
value={selectedTemplate}
onChange={handleTemplateChange}
className="w-full"
size="middle"
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<div
style={{
padding: "8px 12px",
cursor: "pointer",
color: "#1890ff",
fontSize: "12px",
display: "flex",
alignItems: "center",
gap: "4px",
}}
onClick={(e) => {
e.preventDefault();
window.open("https://models.litellm.ai/guardrails", "_blank");
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#f0f0f0";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<UsergroupAddOutlined />
<span>Browse Community templates</span>
<ExportOutlined style={{ fontSize: "10px" }} />
</div>
</>
)}
<div className="w-[280px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Mode (can select multiple)</label>
<Combobox
items={MODE_OPTIONS}
value={selectedModeOptions}
onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))}
multiple
>
<Select.OptGroup label="STANDARD">
{Object.entries(CODE_TEMPLATES).map(([key, template]) => (
<Select.Option key={key} value={key}>
{template.name}
</Select.Option>
<ComboboxChips className="w-full">
{selectedModeOptions.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))}
</Select.OptGroup>
<ComboboxChipsInput
className="border-0 bg-transparent"
placeholder={mode.length === 0 ? "Select modes" : undefined}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No matching modes</ComboboxEmpty>
<ComboboxList>
{(option: ModeOption) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="w-[180px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Template</label>
<Select
items={TEMPLATE_ITEMS}
value={selectedTemplate}
onValueChange={(value: string | null) => value && handleTemplateChange(value)}
>
<SelectTrigger className="w-full" aria-label="Template">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectLabel>STANDARD</SelectLabel>
{TEMPLATE_ITEMS.map((template) => (
<SelectItem key={template.value} value={template.value}>
{template.label}
</SelectItem>
))}
</SelectGroup>
<SelectSeparator />
<button
type="button"
onClick={() => window.open("https://models.litellm.ai/guardrails", "_blank")}
className="flex w-full items-center gap-1 rounded-sm px-2 py-1.5 text-xs text-primary hover:bg-accent"
>
<Users className="size-3.5" />
<span>Browse Community templates</span>
<ExternalLink className="size-2.5" />
</button>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 pt-5">
<span className="text-sm text-gray-600">Default On</span>
<Switch checked={defaultOn} onChange={setDefaultOn} />
<span className="text-sm text-muted-foreground">Default On</span>
<Switch checked={defaultOn} onCheckedChange={setDefaultOn} aria-label="Default On" />
</div>
</div>
{/* Main Content */}
<div className="flex flex-1 overflow-hidden mt-4 gap-6">
<div className="mt-4 flex gap-6">
{/* Code Editor */}
<div className="flex-2 flex flex-col min-w-0 overflow-y-auto">
<div className="flex items-center justify-between mb-2 shrink-0">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Python Logic</span>
<span className="text-xs text-gray-400">Restricted environment (no imports)</span>
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Python Logic</span>
<span className="text-xs text-muted-foreground">Restricted environment (no imports)</span>
</div>
<div
className="relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e] shrink-0"
@ -607,27 +630,23 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</div>
{/* Test Section */}
<Collapse
activeKey={testExpanded ? ["test"] : []}
onChange={(keys) => setTestExpanded(keys.includes("test"))}
className="mt-3 bg-white border border-gray-200 rounded-lg shrink-0"
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
<Collapsible
open={testExpanded}
onOpenChange={setTestExpanded}
className="mt-3 shrink-0 rounded-lg border border-border"
>
<Panel
header={
<span className="flex items-center gap-2 text-sm font-medium">
<PlayCircleOutlined className="text-blue-500" />
Test Your Guardrail
</span>
}
key="test"
>
<CollapsibleTrigger className="flex w-full items-center gap-2 p-3 text-sm font-medium">
<ChevronRight className={`size-4 transition-transform ${testExpanded ? "rotate-90" : ""}`} />
<PlayCircle className="size-4 text-muted-foreground" />
Test Your Guardrail
</CollapsibleTrigger>
<CollapsibleContent className="p-3 pt-0">
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-gray-600">Test Input (JSON)</label>
<label className="block text-xs font-medium text-muted-foreground">Test Input (JSON)</label>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Load example:</span>
<span className="text-xs text-muted-foreground">Load example:</span>
<button
type="button"
onClick={() => setTestInput(JSON.stringify(TEST_INPUT_EXAMPLES.pre_call.data, null, 2))}
@ -651,7 +670,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</button>
</div>
</div>
<div className="mb-2 p-2 bg-gray-50 rounded-sm text-xs text-gray-600 border border-gray-200">
<div className="mb-2 rounded-sm border border-border bg-muted/40 p-2 text-xs text-muted-foreground">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<div>
<strong>texts</strong>: Message content (always)
@ -676,16 +695,17 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</div>
</div>
</div>
<TextArea
<Textarea
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
rows={8}
className="font-mono text-xs"
className="font-mono text-xs field-sizing-fixed"
placeholder='{"texts": ["test message"], ...}'
/>
</div>
<div className="flex items-center gap-3">
<Button size="xs" onClick={handleTest} disabled={isTesting} icon={PlayCircleOutlined}>
<Button size="sm" onClick={handleTest} disabled={isTesting} aria-busy={isTesting}>
{isTesting ? <UiLoadingSpinner className="size-4" /> : <PlayCircle />}
{isTesting ? "Running..." : "Run Test"}
</Button>
{testResult && (
@ -702,7 +722,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
>
{testResult.error ? (
<>
<CloseCircleOutlined />
<XCircle className="size-4" />
<span>
{testResult.error_type && <span className="font-medium">[{testResult.error_type}] </span>}
{testResult.error}
@ -710,140 +730,117 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</>
) : testResult.action === "allow" ? (
<>
<CheckCircleOutlined /> Allowed
<CheckCircle2 className="size-4" /> Allowed
</>
) : testResult.action === "block" ? (
<>
<CloseCircleOutlined /> Blocked: {testResult.reason}
<XCircle className="size-4" /> Blocked: {testResult.reason}
</>
) : testResult.action === "modify" ? (
<>
<CheckCircleOutlined /> Modified
<CheckCircle2 className="size-4" /> Modified
{testResult.texts && testResult.texts.length > 0 && (
<span className="text-xs text-gray-500 ml-1">
→ {testResult.texts[0].substring(0, 50)}
<span className="ml-1 text-xs text-muted-foreground">
-&gt; {testResult.texts[0].substring(0, 50)}
{testResult.texts[0].length > 50 ? "..." : ""}
</span>
)}
</>
) : (
<>
<CheckCircleOutlined /> {testResult.action || "Unknown"}
<CheckCircle2 className="size-4" /> {testResult.action || "Unknown"}
</>
)}
</div>
)}
</div>
</div>
</Panel>
</Collapse>
</CollapsibleContent>
</Collapsible>
{/* Contribution CTA Banner */}
<div className="mt-3 p-4 bg-linear-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg flex items-center justify-between shrink-0">
<div className="mt-3 flex shrink-0 items-center justify-between rounded-lg border border-blue-200 bg-linear-to-r from-blue-50 to-indigo-50 p-4">
<div className="flex items-center gap-3">
<div className="bg-blue-100 rounded-full p-2">
<UsergroupAddOutlined className="text-blue-600 text-lg" />
<div className="rounded-full bg-blue-100 p-2">
<Users className="size-5 text-blue-600" />
</div>
<div>
<div className="text-sm font-medium text-gray-900">Built a useful guardrail?</div>
<div className="text-xs text-gray-600">Share it with the community and help others build faster</div>
<div className="text-sm font-medium">Built a useful guardrail?</div>
<div className="text-xs text-muted-foreground">
Share it with the community and help others build faster
</div>
</div>
</div>
<Button
size="xs"
onClick={() => window.open("https://github.com/BerriAI/litellm-guardrails", "_blank")}
icon={ExportOutlined}
className="bg-blue-600 hover:bg-blue-700 text-white border-0"
>
<Button size="sm" onClick={() => window.open("https://github.com/BerriAI/litellm-guardrails", "_blank")}>
<ExternalLink />
Contribute Template
</Button>
</div>
</div>
{/* Primitives Panel */}
<div className="w-[300px] shrink-0 overflow-auto border-l border-gray-200 pl-6">
<div className="flex items-center gap-2 mb-3">
<CodeOutlined className="text-blue-500" />
<span className="font-semibold text-gray-700">Available Primitives</span>
<div className="w-[300px] shrink-0 overflow-auto border-l border-border pl-6">
<div className="mb-3 flex items-center gap-2">
<Code className="size-4 text-muted-foreground" />
<span className="font-semibold">Available Primitives</span>
</div>
<p className="text-xs text-gray-500 mb-3">Click to copy functions to clipboard</p>
<p className="mb-3 text-xs text-muted-foreground">Click to copy functions to clipboard</p>
<Collapse
defaultActiveKey={["Return Values"]}
className="primitives-collapse bg-transparent border-0"
expandIconPosition="end"
>
<div className="space-y-2">
{Object.entries(PRIMITIVES).map(([category, primitives]) => (
<Panel
header={<span className="text-sm font-medium text-gray-700">{category}</span>}
<Collapsible
key={category}
className="bg-white mb-2 rounded-lg border border-gray-200"
defaultOpen={category === "Return Values"}
className="rounded-lg border border-border"
>
<div className="space-y-2">
{primitives.map((p) => (
<button
key={p.name}
onClick={() => copyPrimitive(p.name)}
className={`w-full text-left px-2 py-2 rounded transition-colors ${
copiedPrimitive === p.name ? "bg-green-100" : "bg-gray-50 hover:bg-blue-50"
}`}
>
{copiedPrimitive === p.name ? (
<span className="flex items-center gap-1 text-xs font-mono text-green-700">
<CheckCircleOutlined /> Copied!
</span>
) : (
<>
<div className="text-xs font-mono text-gray-800">{p.name}</div>
<div className="text-[10px] text-gray-500 mt-0.5">{p.desc}</div>
</>
)}
</button>
))}
</div>
</Panel>
<CollapsibleTrigger className="group flex w-full items-center justify-between px-3 py-2 text-sm font-medium">
{category}
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3">
<div className="space-y-2">
{primitives.map((p) => (
<button
key={p.name}
onClick={() => copyPrimitive(p.name)}
className={`w-full rounded-sm px-2 py-2 text-left transition-colors ${
copiedPrimitive === p.name ? "bg-accent" : "bg-muted/40 hover:bg-accent"
}`}
>
{copiedPrimitive === p.name ? (
<span className="flex items-center gap-1 font-mono text-xs">
<CheckCircle2 className="size-3.5" /> Copied!
</span>
) : (
<>
<div className="font-mono text-xs">{p.name}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{p.desc}</div>
</>
)}
</button>
))}
</div>
</CollapsibleContent>
</Collapsible>
))}
</Collapse>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-200">
<span className="text-xs text-gray-400">Changes are auto-saved to local draft</span>
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
<span className="text-xs text-muted-foreground">Changes are auto-saved to local draft</span>
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSave}
loading={isSaving}
disabled={isSaving || !guardrailName.trim()}
icon={SaveOutlined}
>
<Button onClick={handleSave} disabled={isSaving || !guardrailName.trim()} aria-busy={isSaving}>
{isSaving ? <UiLoadingSpinner className="size-4" /> : <Save />}
{isEditMode ? "Update Guardrail" : "Save Guardrail"}
</Button>
</div>
</div>
</div>
<style>{`
.custom-code-modal .ant-modal-content {
padding: 24px;
}
.custom-code-modal .ant-modal-close {
top: 20px;
right: 20px;
}
.primitives-collapse .ant-collapse-item {
border: none !important;
}
.primitives-collapse .ant-collapse-header {
padding: 8px 12px !important;
}
.primitives-collapse .ant-collapse-content-box {
padding: 8px 12px !important;
}
`}</style>
</Modal>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import GuardrailGarden from "./guardrail_garden";
import { ALL_CARDS } from "./guardrail_garden_data";
vi.mock("./guardrail_garden_detail", () => ({
__esModule: true,
default: ({ card, onBack }: { card: { name: string }; onBack: () => void }) => (
<div>
<span>Detail for {card.name}</span>
<button onClick={onBack}>Back to garden</button>
</div>
),
}));
const LITELLM_CARDS = ALL_CARDS.filter((c) => c.category === "litellm");
const PARTNER_CARDS = ALL_CARDS.filter((c) => c.category === "partner");
describe("GuardrailGarden", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const renderGarden = () => render(<GuardrailGarden accessToken="test-token" onGuardrailCreated={vi.fn()} />);
it("should render both sections with their descriptions", () => {
renderGarden();
expect(screen.getByText("LiteLLM Content Filter")).toBeInTheDocument();
expect(
screen.getByText(
"Built-in guardrails powered by LiteLLM. Zero latency, no external dependencies, no additional cost.",
),
).toBeInTheDocument();
expect(screen.getByText("Partner Guardrails")).toBeInTheDocument();
expect(
screen.getByText("Third-party guardrail integrations from leading AI security providers."),
).toBeInTheDocument();
});
it("should show a capped set of litellm cards behind a show all toggle", async () => {
const user = userEvent.setup();
renderGarden();
expect(screen.getByText(`Show all (${LITELLM_CARDS.length})`)).toBeInTheDocument();
expect(screen.getByText(LITELLM_CARDS[0].name)).toBeInTheDocument();
expect(screen.queryByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).not.toBeInTheDocument();
await user.click(screen.getByText(`Show all (${LITELLM_CARDS.length})`));
expect(screen.getByText("Show less")).toBeInTheDocument();
expect(screen.getByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).toBeInTheDocument();
});
it("should always render every partner card", () => {
renderGarden();
PARTNER_CARDS.forEach((card) => {
expect(screen.getByText(card.name)).toBeInTheDocument();
});
});
it("should filter cards by the search query", async () => {
const user = userEvent.setup();
renderGarden();
const target = PARTNER_CARDS[0];
await user.type(screen.getByPlaceholderText("Search guardrails"), target.name);
expect(await screen.findByText(target.name)).toBeInTheDocument();
const otherPartner = PARTNER_CARDS.find((c) => c.name !== target.name);
if (otherPartner) {
expect(screen.queryByText(otherPartner.name)).not.toBeInTheDocument();
}
});
it("should show an empty result set for a query that matches nothing", async () => {
const user = userEvent.setup();
renderGarden();
await user.type(screen.getByPlaceholderText("Search guardrails"), "zzzzznotaguardrailzzzzz");
expect(screen.getByText("Show all (0)")).toBeInTheDocument();
PARTNER_CARDS.forEach((card) => {
expect(screen.queryByText(card.name)).not.toBeInTheDocument();
});
});
it("should open the detail view for a clicked card and return to the garden", async () => {
const user = userEvent.setup();
renderGarden();
const target = PARTNER_CARDS[0];
await user.click(screen.getByText(target.name));
expect(await screen.findByText(`Detail for ${target.name}`)).toBeInTheDocument();
expect(screen.queryByPlaceholderText("Search guardrails")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back to garden" }));
expect(await screen.findByPlaceholderText("Search guardrails")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Input } from "antd";
import { SearchOutlined, ArrowRightOutlined } from "@ant-design/icons";
import { ArrowRight, Search } from "lucide-react";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { GuardrailCardInfo, ALL_CARDS } from "./guardrail_garden_data";
import GuardrailCard from "./guardrail_garden_card";
import GuardrailDetailView from "./guardrail_garden_detail";
@ -43,72 +43,52 @@ const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardr
return (
<div>
{/* Search Bar */}
<div style={{ marginBottom: 24 }}>
<Input
size="large"
placeholder="Search guardrails"
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ borderRadius: 8 }}
/>
<div className="mb-6">
<InputGroup>
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search guardrails"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
</div>
{/* LiteLLM Content Filter Section */}
<div style={{ marginBottom: 40 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
<h2 style={{ fontSize: 20, fontWeight: 600, color: "#111827", margin: 0 }}>LiteLLM Content Filter</h2>
<div className="mb-10">
<div className="mb-1 flex items-center justify-between">
<h2 className="m-0 text-xl font-semibold text-foreground">LiteLLM Content Filter</h2>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 14,
color: "#1a73e8",
cursor: "pointer",
}}
className="inline-flex cursor-pointer items-center gap-1.5 text-sm text-primary"
onClick={() => setShowAllLitellm(!showAllLitellm)}
>
{showAllLitellm ? (
<>Show less</>
) : (
<>
<ArrowRightOutlined style={{ fontSize: 12 }} />
<ArrowRight className="size-3" />
{`Show all (${litellmCards.length})`}
</>
)}
</span>
</div>
<p style={{ fontSize: 13, color: "#6b7280", margin: "4px 0 20px 0" }}>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">
Built-in guardrails powered by LiteLLM. Zero latency, no external dependencies, no additional cost.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 16,
}}
>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
{(showAllLitellm ? litellmCards : litellmCards.slice(0, CARDS_PER_ROW * VISIBLE_ROWS)).map((card) => (
<GuardrailCard key={card.id} card={card} onClick={() => setSelectedCard(card)} />
))}
</div>
</div>
{/* Partner Guardrails Section */}
<div style={{ marginBottom: 40 }}>
<h2 style={{ fontSize: 20, fontWeight: 600, color: "#111827", margin: "0 0 4px 0" }}>Partner Guardrails</h2>
<p style={{ fontSize: 13, color: "#6b7280", margin: "4px 0 20px 0" }}>
<div className="mb-10">
<h2 className="mt-0 mb-1 text-xl font-semibold text-foreground">Partner Guardrails</h2>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">
Third-party guardrail integrations from leading AI security providers.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 16,
}}
>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
{partnerCards.map((card) => (
<GuardrailCard key={card.id} card={card} onClick={() => setSelectedCard(card)} />
))}

View file

@ -1,45 +1,25 @@
import React, { useState } from "react";
import { CheckCircleFilled } from "@ant-design/icons";
import React from "react";
import { CircleCheck } from "lucide-react";
import { GuardrailCardInfo } from "./guardrail_garden_data";
import { Logo } from "@/components/molecules/logo/Logo";
const GuardrailCard: React.FC<{ card: GuardrailCardInfo; onClick: () => void }> = ({ card, onClick }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
borderRadius: 12,
border: hovered ? "1px solid #93c5fd" : "1px solid #e5e7eb",
backgroundColor: "#ffffff",
padding: "20px 20px 16px 20px",
cursor: "pointer",
transition: "border-color 0.15s, box-shadow 0.15s",
display: "flex",
flexDirection: "column",
minHeight: 170,
boxShadow: hovered ? "0 1px 6px rgba(59,130,246,0.08)" : "none",
}}
className="flex min-h-[170px] cursor-pointer flex-col rounded-xl border border-border bg-card px-5 pt-5 pb-4 transition-[border-color,box-shadow] hover:border-primary/40 hover:shadow-sm"
>
{/* Icon + Name row */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
<div className="mb-2.5 flex items-center gap-2.5">
<Logo src={card.logo} label={card.name} className="w-7 h-7 rounded-md object-contain shrink-0" />
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", lineHeight: 1.3 }}>{card.name}</span>
<span className="text-sm leading-tight font-semibold text-foreground">{card.name}</span>
</div>
{/* Description */}
<p className="line-clamp-3" style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6, margin: 0, flex: 1 }}>
{card.description}
</p>
<p className="line-clamp-3 m-0 flex-1 text-xs leading-relaxed text-muted-foreground">{card.description}</p>
{/* Eval badge */}
{card.eval && (
<div style={{ marginTop: 10, display: "flex", alignItems: "center", gap: 4 }}>
<CheckCircleFilled style={{ color: "#16a34a", fontSize: 12 }} />
<span style={{ fontSize: 11, color: "#16a34a", fontWeight: 500 }}>
<div className="mt-2.5 flex items-center gap-1 text-emerald-600">
<CircleCheck className="size-3" />
<span className="text-[11px] font-medium">
F1: {card.eval.f1}% &middot; {card.eval.testCases} test cases
</span>
</div>

View file

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Button } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import AddGuardrailForm from "./add_guardrail_form";
import { Logo } from "@/components/molecules/logo/Logo";
import { GUARDRAIL_PRESETS } from "./guardrail_garden_configs";
@ -44,17 +44,9 @@ const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack,
{/* Back link */}
<div
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "#5f6368",
cursor: "pointer",
fontSize: 14,
marginBottom: 24,
}}
className="mb-6 inline-flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground"
>
<ArrowLeftOutlined style={{ fontSize: 11 }} />
<ArrowLeft className="size-3" />
<span>{card.name}</span>
</div>
@ -67,19 +59,8 @@ const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack,
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 20px 0", lineHeight: 1.6 }}>{card.description}</p>
{/* Action buttons — outlined style like Vertex */}
<div style={{ display: "flex", gap: 10, marginBottom: 32 }}>
<Button
onClick={() => setIsAddFormVisible(true)}
style={{
borderRadius: 20,
padding: "4px 20px",
height: 36,
borderColor: "#dadce0",
color: "#1a73e8",
fontWeight: 500,
fontSize: 14,
}}
>
<div className="mb-8 flex gap-2.5">
<Button variant="outline" className="rounded-full" onClick={() => setIsAddFormVisible(true)}>
Create Guardrail
</Button>
</div>

View file

@ -1,10 +1,21 @@
import React from "react";
import { Typography, Select, Button, Checkbox, Tooltip, Tag } from "antd";
import { CloseOutlined, EyeInvisibleOutlined, StopOutlined, FilterOutlined } from "@ant-design/icons";
import { EyeOff, Filter, Info, Ban, X } from "lucide-react";
import { PiiEntityCategory } from "@/components/guardrails/types";
const { Text } = Typography;
const { Option } = Select;
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Helper functions
export const formatEntityName = (name: string) => {
@ -14,9 +25,9 @@ export const formatEntityName = (name: string) => {
export const getActionIcon = (action: string) => {
switch (action) {
case "MASK":
return <EyeInvisibleOutlined style={{ marginRight: 4 }} />;
return <EyeOff className="mr-1 size-3.5" />;
case "BLOCK":
return <StopOutlined style={{ marginRight: 4 }} />;
return <Ban className="mr-1 size-3.5" />;
default:
return null;
}
@ -30,34 +41,37 @@ export interface CategoryFilterProps {
}
export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
const categoryNames = categories.map((cat) => cat.category);
return (
<div>
<div className="flex items-center mb-2">
<FilterOutlined className="text-gray-500 mr-1" />
<Text className="text-gray-500 font-medium">Filter by category</Text>
<div className="mb-2 flex items-center">
<Filter className="mr-1 size-4 text-muted-foreground" />
<span className="font-medium text-muted-foreground">Filter by category</span>
</div>
<Select
mode="multiple"
placeholder="Select categories to filter by"
style={{ width: "100%" }}
onChange={onChange}
value={selectedCategories}
allowClear
showSearch
optionFilterProp="children"
className="mb-4"
tagRender={(props) => (
<Tag color="blue" closable={props.closable} onClose={props.onClose} className="mr-2 mb-2">
{props.label}
</Tag>
)}
>
{categories.map((cat) => (
<Option key={cat.category} value={cat.category}>
{cat.category}
</Option>
))}
</Select>
<Combobox items={categoryNames} value={selectedCategories} onValueChange={onChange} multiple>
<ComboboxChips className="mb-4 w-full">
{selectedCategories.map((category) => (
<ComboboxChip key={category} aria-label={category}>
{category}
</ComboboxChip>
))}
<ComboboxChipsInput
className="border-0 bg-transparent"
placeholder={selectedCategories.length === 0 ? "Select categories to filter by" : undefined}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No matching categories</ComboboxEmpty>
<ComboboxList>
{(category: string) => (
<ComboboxItem key={category} value={category}>
{category}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
};
@ -71,46 +85,34 @@ export interface QuickActionsProps {
export const QuickActions: React.FC<QuickActionsProps> = ({ onSelectAll, onUnselectAll, hasSelectedEntities }) => {
return (
<div className="bg-gray-50 p-5 rounded-lg mb-6 border border-gray-200 shadow-xs">
<div className="flex items-center justify-between mb-4">
<div className="mb-6 rounded-lg border border-border bg-muted/40 p-5 shadow-xs">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center">
<Text strong className="text-gray-700 text-base">
Quick Actions
</Text>
<Tooltip title="Apply action to all PII types at once">
<div className="ml-2 text-gray-400 cursor-help text-xs">ⓘ</div>
<span className="text-base font-semibold">Quick Actions</span>
<Tooltip>
<TooltipTrigger
render={
<span className="ml-2 cursor-help text-muted-foreground">
<Info className="size-3.5" />
</span>
}
/>
<TooltipContent>Apply action to all PII types at once</TooltipContent>
</Tooltip>
</div>
<Button
color="danger"
variant="outlined"
onClick={onUnselectAll}
disabled={!hasSelectedEntities}
icon={<CloseOutlined />}
>
<Button variant="outline" onClick={onUnselectAll} disabled={!hasSelectedEntities}>
<X />
Unselect All
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
color="primary"
variant="outlined"
onClick={() => onSelectAll("MASK")}
className="h-10"
block
icon={<EyeInvisibleOutlined />}
>
Select All & Mask
<Button variant="outline" className="h-10 w-full" onClick={() => onSelectAll("MASK")}>
<EyeOff />
Select All &amp; Mask
</Button>
<Button
color="danger"
variant="outlined"
onClick={() => onSelectAll("BLOCK")}
className="h-10 hover:bg-red-100"
block
icon={<StopOutlined />}
>
Select All & Block
<Button variant="outline" className="h-10 w-full" onClick={() => onSelectAll("BLOCK")}>
<Ban />
Select All &amp; Block
</Button>
</div>
</div>
@ -138,62 +140,59 @@ export const PiiEntityList: React.FC<PiiEntityListProps> = ({
entityToCategoryMap,
}) => {
return (
<div className="border rounded-lg overflow-hidden shadow-xs">
<div className="bg-gray-50 px-5 py-3 border-b flex">
<Text strong className="flex-1 text-gray-700">
PII Type
</Text>
<Text strong className="w-32 text-right text-gray-700">
Action
</Text>
<div className="overflow-hidden rounded-lg border border-border shadow-xs">
<div className="flex border-b border-border bg-muted/40 px-5 py-3">
<span className="flex-1 font-semibold">PII Type</span>
<span className="w-32 text-right font-semibold">Action</span>
</div>
<div className="max-h-[400px] overflow-y-auto">
{entities.length === 0 ? (
<div className="py-10 text-center text-gray-500">No PII types match your filter criteria</div>
<div className="py-10 text-center text-muted-foreground">No PII types match your filter criteria</div>
) : (
entities.map((entity) => (
<div
key={entity}
className={`px-5 py-3 flex items-center justify-between hover:bg-gray-50 border-b ${
selectedEntities.includes(entity) ? "bg-blue-50" : ""
}`}
>
<div className="flex items-center flex-1">
<Checkbox
checked={selectedEntities.includes(entity)}
onChange={() => onEntitySelect(entity)}
className="mr-3"
/>
<Text className={selectedEntities.includes(entity) ? "font-medium text-gray-900" : "text-gray-700"}>
{formatEntityName(entity)}
</Text>
{entityToCategoryMap.get(entity) && (
<Tag className="ml-2 text-xs" color="blue">
{entityToCategoryMap.get(entity)}
</Tag>
)}
entities.map((entity) => {
const isSelected = selectedEntities.includes(entity);
return (
<div
key={entity}
className={`flex items-center justify-between border-b border-border px-5 py-3 hover:bg-muted/40 ${
isSelected ? "bg-accent" : ""
}`}
>
<div className="flex flex-1 items-center">
<Checkbox className="mr-3" checked={isSelected} onCheckedChange={() => onEntitySelect(entity)} />
<span className={isSelected ? "font-medium text-foreground" : "text-muted-foreground"}>
{formatEntityName(entity)}
</span>
{entityToCategoryMap.get(entity) && (
<Badge variant="secondary" className="ml-2">
{entityToCategoryMap.get(entity)}
</Badge>
)}
</div>
<div className="w-32">
<Select
value={isSelected ? selectedActions[entity] || "MASK" : "MASK"}
onValueChange={(value: string | null) => value && onActionSelect(entity, value)}
disabled={!isSelected}
>
<SelectTrigger className={`w-[120px] ${isSelected ? "" : "opacity-50"}`} aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{actions.map((action) => (
<SelectItem key={action} value={action}>
<span className="flex items-center">
{getActionIcon(action)}
{action}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="w-32">
<Select
value={selectedEntities.includes(entity) ? selectedActions[entity] || "MASK" : "MASK"}
onChange={(value) => onActionSelect(entity, value)}
style={{ width: 120 }}
disabled={!selectedEntities.includes(entity)}
className={`${!selectedEntities.includes(entity) ? "opacity-50" : ""}`}
dropdownMatchSelectWidth={false}
>
{actions.map((action) => (
<Option key={action} value={action}>
<div className="flex items-center">
{getActionIcon(action)}
{action}
</div>
</Option>
))}
</Select>
</div>
</div>
))
);
})
)}
</div>
</div>

View file

@ -1,10 +1,7 @@
import { Typography } from "antd";
import React, { useState } from "react";
import { CategoryFilter, PiiEntityList, QuickActions } from "./pii_components";
import { PiiConfigurationProps } from "@/components/guardrails/types";
const { Title, Text } = Typography;
/**
* A reusable component for rendering PII entity selection and action configuration
* Used in both add and edit guardrail forms
@ -57,11 +54,9 @@ const PiiConfiguration: React.FC<PiiConfigurationProps> = ({
<div className="pii-configuration">
<div className="flex justify-between items-center mb-5">
<div className="flex items-center">
<Title level={4} className="m-0! font-semibold text-gray-800">
Configure PII Protection
</Title>
<h4 className="m-0 text-lg font-semibold text-foreground">Configure PII Protection</h4>
</div>
<Text className="text-gray-500">{selectedEntities.length} items selected</Text>
<span className="text-muted-foreground">{selectedEntities.length} items selected</span>
</div>
<div className="mb-6">

View file

@ -1,7 +1,12 @@
import React from "react";
import { Card, Text } from "@tremor/react";
import { Button, Divider, Empty, Input, Select, Space, Tooltip } from "antd";
import { InfoCircleOutlined, PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { Info, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export type ToolPermissionDecision = "allow" | "deny";
export type ToolPermissionDefaultAction = "allow" | "deny";
@ -28,6 +33,16 @@ interface ToolPermissionRulesEditorProps {
disabled?: boolean;
}
const DECISION_ITEMS = [
{ value: "allow", label: "Allow" },
{ value: "deny", label: "Deny" },
] as const;
const ON_DISALLOWED_ITEMS = [
{ value: "block", label: "Block" },
{ value: "rewrite", label: "Rewrite" },
] as const;
const DEFAULT_CONFIG: ToolPermissionConfig = {
rules: [],
default_action: "deny",
@ -115,8 +130,9 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
if (entries.length === 0) {
return (
<Button
variant="outline"
disabled={disabled}
size="small"
size="sm"
onClick={() => updateRule(index, { allowed_param_patterns: { "": "" } })}
>
+ Restrict tool arguments (optional)
@ -126,9 +142,9 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
return (
<div className="space-y-2">
<Text className="text-sm text-gray-600">Argument constraints (dot or array paths)</Text>
<p className="text-sm text-muted-foreground">Argument constraints (dot or array paths)</p>
{entries.map(([path, pattern], patternIndex) => (
<Space key={`${rule.id || index}-${patternIndex}`} align="start">
<div key={`${rule.id || index}-${patternIndex}`} className="flex items-start gap-2">
<Input
disabled={disabled}
placeholder="messages[0].content"
@ -142,20 +158,24 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
onChange={(e) => updateAllowedParamPattern(index, patternIndex, e.target.value)}
/>
<Button
variant="outline"
size="icon"
aria-label="Remove constraint"
disabled={disabled}
icon={<DeleteOutlined />}
danger
onClick={() =>
updateAllowedParamEntries(index, (entries) => {
entries.splice(patternIndex, 1);
})
}
/>
</Space>
>
<Trash2 />
</Button>
</div>
))}
<Button
variant="outline"
disabled={disabled}
size="small"
size="sm"
onClick={() =>
updateRule(index, {
allowed_param_patterns: {
@ -173,148 +193,186 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
return (
<Card>
<div className="flex items-center justify-between">
<div>
<Text className="text-lg font-semibold">LiteLLM Tool Permission Guardrail</Text>
<Text className="text-sm text-gray-500">
Provide regex patterns (e.g., ^mcp__github_.*$) for tool names or types and optionally constrain payload
fields.
</Text>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-lg font-semibold">LiteLLM Tool Permission Guardrail</p>
<p className="text-sm text-muted-foreground">
Provide regex patterns (e.g., ^mcp__github_.*$) for tool names or types and optionally constrain payload
fields.
</p>
</div>
{!disabled && (
<Button onClick={addRule}>
<Plus />
Add Rule
</Button>
)}
</div>
{!disabled && (
<Button
icon={<PlusOutlined />}
type="primary"
onClick={addRule}
className="bg-blue-600! text-white! hover:bg-blue-500!"
>
Add Rule
</Button>
<Separator className="my-4" />
{config.rules.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">No tool rules added yet</div>
) : (
<div className="space-y-4">
{config.rules.map((rule, index) => (
<Card key={rule.id || index} className="bg-muted/40">
<CardContent>
<div className="mb-3 flex items-center justify-between">
<p className="font-semibold">Rule {index + 1}</p>
<Button variant="ghost" disabled={disabled} onClick={() => removeRule(index)}>
<Trash2 />
Remove
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium">Rule ID</p>
<Input
disabled={disabled}
placeholder="unique_rule_id"
value={rule.id}
onChange={(e) => updateRule(index, { id: e.target.value })}
/>
</div>
<div>
<p className="text-sm font-medium">Tool Name (optional)</p>
<Input
disabled={disabled}
placeholder="^mcp__github_.*$"
value={rule.tool_name ?? ""}
onChange={(e) =>
updateRule(index, {
tool_name: e.target.value.trim() === "" ? undefined : e.target.value,
})
}
/>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium">Tool Type (optional)</p>
<Input
disabled={disabled}
placeholder="^function$"
value={rule.tool_type ?? ""}
onChange={(e) =>
updateRule(index, {
tool_type: e.target.value.trim() === "" ? undefined : e.target.value,
})
}
/>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<p className="text-sm font-medium">Decision</p>
<Select
items={DECISION_ITEMS}
disabled={disabled}
value={rule.decision}
onValueChange={(value: string | null) =>
value && updateRule(index, { decision: value as ToolPermissionDecision })
}
>
<SelectTrigger className="w-[200px]" aria-label="Decision">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{DECISION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-4">{renderAllowedParamPatterns(rule, index)}</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<Divider />
<Separator className="my-4" />
{config.rules.length === 0 ? (
<Empty description="No tool rules added yet" />
) : (
<div className="space-y-4">
{config.rules.map((rule, index) => (
<Card key={rule.id || index} className="bg-gray-50">
<div className="flex items-center justify-between mb-3">
<Text className="font-semibold">Rule {index + 1}</Text>
<Button
icon={<DeleteOutlined />}
danger
type="text"
disabled={disabled}
onClick={() => removeRule(index)}
>
Remove
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Text className="text-sm font-medium">Rule ID</Text>
<Input
disabled={disabled}
placeholder="unique_rule_id"
value={rule.id}
onChange={(e) => updateRule(index, { id: e.target.value })}
/>
</div>
<div>
<Text className="text-sm font-medium">Tool Name (optional)</Text>
<Input
disabled={disabled}
placeholder="^mcp__github_.*$"
value={rule.tool_name ?? ""}
onChange={(e) =>
updateRule(index, {
tool_name: e.target.value.trim() === "" ? undefined : e.target.value,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 mt-4">
<div>
<Text className="text-sm font-medium">Tool Type (optional)</Text>
<Input
disabled={disabled}
placeholder="^function$"
value={rule.tool_type ?? ""}
onChange={(e) =>
updateRule(index, {
tool_type: e.target.value.trim() === "" ? undefined : e.target.value,
})
}
/>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<Text className="text-sm font-medium">Decision</Text>
<Select
disabled={disabled}
value={rule.decision}
style={{ width: 200 }}
onChange={(value) => updateRule(index, { decision: value as ToolPermissionDecision })}
>
<Select.Option value="allow">Allow</Select.Option>
<Select.Option value="deny">Deny</Select.Option>
</Select>
</div>
<div className="mt-4">{renderAllowedParamPatterns(rule, index)}</div>
</Card>
))}
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium">Default action</p>
<Select
items={DECISION_ITEMS}
disabled={disabled}
value={config.default_action}
onValueChange={(value: string | null) =>
value && updateConfig({ default_action: value as ToolPermissionDefaultAction })
}
>
<SelectTrigger className="w-full" aria-label="Default action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{DECISION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="flex items-center gap-1 text-sm font-medium">
On disallowed action
<Tooltip>
<TooltipTrigger
render={
<span className="cursor-help text-muted-foreground">
<Info className="size-3.5" />
</span>
}
/>
<TooltipContent>
Block returns an error when a forbidden tool is invoked. Rewrite strips the tool call but lets the
rest of the response continue.
</TooltipContent>
</Tooltip>
</p>
<Select
items={ON_DISALLOWED_ITEMS}
disabled={disabled}
value={config.on_disallowed_action}
onValueChange={(value: string | null) =>
value && updateConfig({ on_disallowed_action: value as ToolPermissionOnDisallowedAction })
}
>
<SelectTrigger className="w-full" aria-label="On disallowed action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{ON_DISALLOWED_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<Divider />
<div className="grid gap-4 md:grid-cols-2">
<div>
<Text className="text-sm font-medium">Default action</Text>
<Select
<div className="mt-4">
<p className="text-sm font-medium">Violation message (optional)</p>
<Textarea
className="field-sizing-fixed"
disabled={disabled}
value={config.default_action}
onChange={(value) => updateConfig({ default_action: value as ToolPermissionDefaultAction })}
>
<Select.Option value="allow">Allow</Select.Option>
<Select.Option value="deny">Deny</Select.Option>
</Select>
rows={3}
placeholder="This violates our org policy..."
value={config.violation_message_template}
onChange={(e) => updateConfig({ violation_message_template: e.target.value })}
/>
</div>
<div>
<Text className="text-sm font-medium flex items-center gap-1">
On disallowed action
<Tooltip title="Block returns an error when a forbidden tool is invoked. Rewrite strips the tool call but lets the rest of the response continue.">
<InfoCircleOutlined />
</Tooltip>
</Text>
<Select
disabled={disabled}
value={config.on_disallowed_action}
onChange={(value) => updateConfig({ on_disallowed_action: value as ToolPermissionOnDisallowedAction })}
>
<Select.Option value="block">Block</Select.Option>
<Select.Option value="rewrite">Rewrite</Select.Option>
</Select>
</div>
</div>
<div className="mt-4">
<Text className="text-sm font-medium">Violation message (optional)</Text>
<Input.TextArea
disabled={disabled}
rows={3}
placeholder="This violates our org policy..."
value={config.violation_message_template}
onChange={(e) => updateConfig({ violation_message_template: e.target.value })}
/>
</div>
</CardContent>
</Card>
);
};

View file

@ -242,3 +242,13 @@
[data-slot="dialog-content"][data-nested-dialog-open] {
visibility: hidden;
}
/* Base UI portals a popup into an `isolate z-50` positioner, which an antd Modal at z-index 1000
then paints over, so options on a half-migrated page are visible but not clickable. The
positioner is not reachable from the call site, hence the parent selector. Delete this once no
route renders an antd Modal. */
body:has(.ant-modal-wrap) div:has(> [data-slot="select-content"]),
body:has(.ant-modal-wrap) div:has(> [data-slot="combobox-content"]),
body:has(.ant-modal-wrap) div:has(> [data-slot="tooltip-content"]) {
z-index: 1100;
}