mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
refactor(ui): migrate guardrails route to shadcn
Ports the 21 route-owned guardrails components off antd and Tremor onto the
installed shadcn base-vega primitives. Forms, tables and shared components stay
where they are: the route analyzer buckets add_guardrail_form, guardrail_info
and six others as DEFERRED, and eleven components under src/components as
SHARED, so none of them are touched here.
Three behaviours needed explicit handling because Base UI does not match antd by
default. Base UI Tabs mounts only the active panel, so the playground draft and
every panel's local state would reset on a tab switch; TabsContent now carries
keepMounted. ComboboxList takes a function child to render the filtered items,
and mapping over the array instead renders everything and silently disables the
type to filter behaviour antd gave for free. SelectContent gets
alignItemWithTrigger={false} so popups anchor below the trigger the way antd's
did, which also removes a focus race that made the template picker
intermittently unclickable.
The three dialogs reached from inside the still-antd wizard need a layer above
its z-index of 1000 to be visible at all.
Adds three tests rather than editing the characterisation tests from the
previous commit: a guard that every tab panel stays mounted, and one filtering
regression each for the pattern and mode dropdowns.
Prunes the guardrails entries from eslint-suppressions.json.
This commit is contained in:
parent
8ada34fe2c
commit
771c36c1ee
27 changed files with 1490 additions and 1438 deletions
|
|
@ -326,28 +326,12 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"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": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -386,11 +370,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
|
||||
|
|
@ -403,14 +382,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
|
||||
|
|
@ -419,62 +392,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
|
||||
}
|
||||
|
|
@ -487,9 +418,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": {
|
||||
|
|
@ -500,9 +428,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": {
|
||||
|
|
@ -568,23 +493,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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,13 @@ 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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -70,6 +70,20 @@ describe("PatternModal", () => {
|
|||
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 report the chosen action", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
|
|
|||
|
|
@ -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,11 @@ interface PrebuiltPattern {
|
|||
description: string;
|
||||
}
|
||||
|
||||
interface PatternGroup {
|
||||
category: string;
|
||||
items: PrebuiltPattern[];
|
||||
}
|
||||
|
||||
interface PatternModalProps {
|
||||
visible: boolean;
|
||||
prebuiltPatterns: PrebuiltPattern[];
|
||||
|
|
@ -34,64 +51,83 @@ 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}
|
||||
>
|
||||
<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]";
|
||||
|
|
@ -123,6 +123,17 @@ describe("CustomCodeModal", () => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue