mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #36832 from BerriAI/litellm_/quizzical-jennings-5df447
refactor(ui): migrate guardrails to shadcn
This commit is contained in:
commit
89f81919cc
34 changed files with 2283 additions and 1512 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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['metadata']. 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" : ""}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 "Save Changes" at the
|
||||
bottom.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>
|
||||
You have unsaved changes to patterns or keywords. Remember to click "Save Changes" at the bottom.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{guardrailSettings && guardrailSettings.content_filter_settings && (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const ABOVE_ANTD_MODAL = "z-[1100]";
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
-> {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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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)} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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}% · {card.eval.testCases} test cases
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue