mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
refactor(ui): migrate logging-and-alerts, caching and policies to shadcn
Markup-only migration of the 17 files these three routes exclusively own, replacing antd and Tremor with the installed shadcn (base-vega) primitives and lucide icons. No route behaviour changes; the tests written in the previous commit are untouched here and pass against both the old and the new markup. Colour now comes from tokens rather than from hardcoded utilities, so the health-check button, the alerts and the badges no longer pin their own palette. email_settings also loses an invalid DOM nesting (a table cell inside a div, and a div inside a paragraph) that React had been warning about. Two modals on the policies page moved from the Policies panel up to the panel root. Base UI Tabs mounts only the active panel, unlike Tremor, and both are opened from the Templates tab, so leaving them nested would have made "Use Template" do nothing. Retires 53 antd import suppressions from the eslint baseline.
This commit is contained in:
parent
33dc162893
commit
b2cf17d4a2
19 changed files with 1734 additions and 1731 deletions
|
|
@ -177,9 +177,6 @@
|
|||
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": {
|
||||
|
|
@ -187,11 +184,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -213,11 +205,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1469,9 +1456,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 10
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1483,9 +1467,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1509,9 +1490,6 @@
|
|||
"src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/index.test.tsx": {
|
||||
|
|
@ -1526,9 +1504,6 @@
|
|||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1543,9 +1518,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -1554,9 +1526,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1564,9 +1533,6 @@
|
|||
"src/app/(dashboard)/policies/_components/policy_templates.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/policy_test_panel.tsx": {
|
||||
|
|
@ -1584,9 +1550,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
|
|
@ -2218,26 +2181,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -3240,9 +3188,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -3251,9 +3196,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -4116,6 +4058,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/radio-group.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ui/scroll-area.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
import {
|
||||
Card,
|
||||
Col,
|
||||
DateRangePickerValue,
|
||||
Grid,
|
||||
Icon,
|
||||
MultiSelect,
|
||||
MultiSelectItem,
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { DateRangePickerValue } from "@tremor/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import UsageDatePicker from "@/components/shared/usage_date_picker";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { Card as ChartCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking";
|
||||
|
||||
// Import the new component
|
||||
|
|
@ -258,31 +257,42 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
}
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: "Cache Hit Ratio", value: `${cacheHitRatio}%` },
|
||||
{ label: "Cache Hits", value: cachedResponses },
|
||||
{ label: "Cached Completion Tokens", value: cachedTokens },
|
||||
];
|
||||
|
||||
return (
|
||||
<TabGroup className="gap-2 p-8 h-full w-full mt-2 mb-8">
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">
|
||||
<Tab>Cache Analytics</Tab>
|
||||
<Tab>Cache Health</Tab>
|
||||
<Tab>Cache Settings</Tab>
|
||||
<Tab>Coordination Redis</Tab>
|
||||
</div>
|
||||
<Tabs defaultValue="analytics" className="mt-2 mb-8 w-full gap-2 p-8">
|
||||
<div className="mt-2 flex w-full items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="analytics" className="flex-none">
|
||||
Cache Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="health" className="flex-none">
|
||||
Cache Health
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-none">
|
||||
Cache Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="coordination" className="flex-none">
|
||||
Coordination Redis
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
|
||||
<Icon
|
||||
icon={RefreshIcon} // Modify as necessary for correct icon name
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="self-center"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
{lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
|
||||
<Button variant="outline" size="icon-sm" onClick={handleRefreshClick} aria-label="Refresh">
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Text className="text-tremor-content dark:text-dark-tremor-content">
|
||||
</div>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Analytics for LiteLLM's{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/caching"
|
||||
|
|
@ -303,76 +313,92 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
</a>{" "}
|
||||
(cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching
|
||||
Metrics" on the Usage page or individual requests in the Logs page.
|
||||
</Text>
|
||||
<Grid numItems={3} className="gap-4 mt-4">
|
||||
<Col>
|
||||
<MultiSelect
|
||||
placeholder="Select Virtual Keys"
|
||||
value={selectedApiKeys}
|
||||
onValueChange={setSelectedApiKeys}
|
||||
>
|
||||
{uniqueApiKeys.map((key) => (
|
||||
<MultiSelectItem key={key} value={key}>
|
||||
{key}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelect>
|
||||
</Col>
|
||||
<Col>
|
||||
<MultiSelect placeholder="Select Models" value={selectedModels} onValueChange={setSelectedModels}>
|
||||
{uniqueModels.map((model) => (
|
||||
<MultiSelectItem key={model} value={model}>
|
||||
{model}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelect>
|
||||
</Col>
|
||||
<Col>
|
||||
<UsageDatePicker
|
||||
value={dateValue}
|
||||
onValueChange={(value) => {
|
||||
setDateValue(value);
|
||||
updateCachingData(value.from, value.to);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
<Card>
|
||||
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
|
||||
Cache Hit Ratio
|
||||
</p>
|
||||
<div className="mt-2 flex items-baseline space-x-2.5">
|
||||
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
|
||||
{cacheHitRatio}%
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
|
||||
Cache Hits
|
||||
</p>
|
||||
<div className="mt-2 flex items-baseline space-x-2.5">
|
||||
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
|
||||
{cachedResponses}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Combobox
|
||||
multiple
|
||||
items={uniqueApiKeys}
|
||||
value={selectedApiKeys}
|
||||
onValueChange={(keys: string[]) => setSelectedApiKeys(keys)}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxValue>
|
||||
{(keys: string[]) =>
|
||||
keys.map((key) => (
|
||||
<ComboboxChip key={key} aria-label={key}>
|
||||
{key}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select Virtual Keys" className="border-0 bg-transparent" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No virtual keys found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(key: string) => (
|
||||
<ComboboxItem key={key} value={key}>
|
||||
{key}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
<Card>
|
||||
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
|
||||
Cached Completion Tokens
|
||||
</p>
|
||||
<div className="mt-2 flex items-baseline space-x-2.5">
|
||||
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
|
||||
{cachedTokens}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Combobox
|
||||
multiple
|
||||
items={uniqueModels}
|
||||
value={selectedModels}
|
||||
onValueChange={(models: string[]) => setSelectedModels(models)}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxValue>
|
||||
{(models: string[]) =>
|
||||
models.map((model) => (
|
||||
<ComboboxChip key={model} aria-label={model}>
|
||||
{model}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select Models" className="border-0 bg-transparent" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
<UsageDatePicker
|
||||
value={dateValue}
|
||||
onValueChange={(value) => {
|
||||
setDateValue(value);
|
||||
updateCachingData(value.from, value.to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChartCard className="mt-4">
|
||||
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{statCards.map((stat) => (
|
||||
<Card key={stat.label}>
|
||||
<CardContent>
|
||||
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||
<div className="mt-2 flex items-baseline space-x-2.5">
|
||||
<p className="text-3xl font-semibold">{stat.value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Cache Hits vs API Requests</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -387,9 +413,9 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
yAxisWidth={48}
|
||||
/>
|
||||
</CardContent>
|
||||
</ChartCard>
|
||||
</Card>
|
||||
|
||||
<ChartCard className="mt-6">
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Cached Completion Tokens vs Generated Completion Tokens
|
||||
|
|
@ -406,24 +432,27 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
yAxisWidth={48}
|
||||
/>
|
||||
</CardContent>
|
||||
</ChartCard>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<CacheHealthTab
|
||||
accessToken={accessToken}
|
||||
healthCheckResponse={healthCheckResponse}
|
||||
runCachingHealthCheck={runCachingHealthCheck}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<CacheSettings accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<CoordinationRedisSettings />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="health">
|
||||
<CacheHealthTab
|
||||
accessToken={accessToken}
|
||||
healthCheckResponse={healthCheckResponse}
|
||||
runCachingHealthCheck={runCachingHealthCheck}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<CacheSettings accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="coordination">
|
||||
<CoordinationRedisSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Text, Button, TabGroup, TabList, Tab, TabPanel, TabPanels } from "@tremor/react";
|
||||
import { CheckCircleIcon, XCircleIcon, ClipboardCopyIcon } from "@heroicons/react/outline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CheckCircle2, XCircle, ClipboardCopy } from "lucide-react";
|
||||
import { ResponseTimeIndicator } from "./response_time_indicator";
|
||||
|
||||
// Helper function to deep-parse a JSON string if possible
|
||||
|
|
@ -30,22 +31,26 @@ const TableClickableErrorField: React.FC<{ label: string; value: string | null |
|
|||
};
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<tr className="hover:bg-muted/50">
|
||||
<td className="px-4 py-2 align-top" colSpan={2}>
|
||||
<div className="flex items-center justify-between group">
|
||||
<div className="flex items-center flex-1">
|
||||
<button onClick={() => setIsExpanded(!isExpanded)} className="text-gray-400 hover:text-gray-600 mr-2">
|
||||
<div className="group flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mr-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</button>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">{label}</div>
|
||||
<pre className="mt-1 text-sm font-mono text-gray-800 whitespace-pre-wrap">
|
||||
{isExpanded ? safeValue : truncated}
|
||||
</pre>
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<pre className="mt-1 font-mono text-sm whitespace-pre-wrap">{isExpanded ? safeValue : truncated}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleCopy} className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600">
|
||||
<ClipboardCopyIcon className="h-4 w-4" />
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
|
||||
>
|
||||
<ClipboardCopy className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -151,112 +156,114 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<TabGroup>
|
||||
<TabList className="border-b border-gray-200 px-4">
|
||||
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">Summary</Tab>
|
||||
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">Raw Response</Tab>
|
||||
</TabList>
|
||||
<div className="rounded-lg bg-card shadow-sm">
|
||||
<Tabs defaultValue="summary">
|
||||
<TabsList className="border-b border-border px-4">
|
||||
<TabsTrigger value="summary" className="flex-none">
|
||||
Summary
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="raw" className="flex-none">
|
||||
Raw Response
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel className="p-4">
|
||||
<div>
|
||||
<div className="flex items-center mb-6">
|
||||
{response?.status === "healthy" ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
||||
) : (
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<TabsContent value="summary" className="p-4">
|
||||
<div>
|
||||
<div className="mb-6 flex items-center">
|
||||
{response?.status === "healthy" ? (
|
||||
<CheckCircle2 className="mr-2 size-5 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="mr-2 size-5 text-destructive" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-medium ${response?.status === "healthy" ? "text-green-600" : "text-destructive"}`}
|
||||
>
|
||||
Cache Status: {response?.status || "unhealthy"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{/* Show error message if present */}
|
||||
{errorDetails && (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold text-destructive">
|
||||
Error Details
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Error Message" value={errorDetails.message} />
|
||||
<TableClickableErrorField label="Traceback" value={errorDetails.traceback} />
|
||||
</>
|
||||
)}
|
||||
<Text
|
||||
className={`text-sm font-medium ${response?.status === "healthy" ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
Cache Status: {response?.status || "unhealthy"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{/* Show error message if present */}
|
||||
{errorDetails && (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold text-red-600">
|
||||
Error Details
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Error Message" value={errorDetails.message} />
|
||||
<TableClickableErrorField label="Traceback" value={errorDetails.traceback} />
|
||||
</>
|
||||
)}
|
||||
{/* Always show cache details, regardless of error state */}
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold">
|
||||
Cache Details
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Cache Configuration" value={String(parsedLitellmParams?.type)} />
|
||||
<TableClickableErrorField label="Ping Response" value={String(response.ping_response)} />
|
||||
<TableClickableErrorField label="Set Cache Response" value={response.set_cache_response || "N/A"} />
|
||||
<TableClickableErrorField
|
||||
label="litellm_settings.cache_params"
|
||||
value={JSON.stringify(parsedLitellmParams, null, 2)}
|
||||
/>
|
||||
|
||||
{/* Always show cache details, regardless of error state */}
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold">
|
||||
Cache Details
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Cache Configuration" value={String(parsedLitellmParams?.type)} />
|
||||
<TableClickableErrorField label="Ping Response" value={String(response.ping_response)} />
|
||||
<TableClickableErrorField label="Set Cache Response" value={response.set_cache_response || "N/A"} />
|
||||
<TableClickableErrorField
|
||||
label="litellm_settings.cache_params"
|
||||
value={JSON.stringify(parsedLitellmParams, null, 2)}
|
||||
/>
|
||||
{/* Redis Details Section */}
|
||||
{parsedLitellmParams?.type === "redis" && (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold">
|
||||
Redis Details
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Redis Host" value={redisDetails.redis_host || "N/A"} />
|
||||
<TableClickableErrorField label="Redis Port" value={redisDetails.redis_port || "N/A"} />
|
||||
<TableClickableErrorField label="Redis Version" value={redisDetails.redis_version || "N/A"} />
|
||||
<TableClickableErrorField label="Startup Nodes" value={redisDetails.startup_nodes || "N/A"} />
|
||||
<TableClickableErrorField label="Namespace" value={redisDetails.namespace || "N/A"} />
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Redis Details Section */}
|
||||
{parsedLitellmParams?.type === "redis" && (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold">
|
||||
Redis Details
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Redis Host" value={redisDetails.redis_host || "N/A"} />
|
||||
<TableClickableErrorField label="Redis Port" value={redisDetails.redis_port || "N/A"} />
|
||||
<TableClickableErrorField label="Redis Version" value={redisDetails.redis_version || "N/A"} />
|
||||
<TableClickableErrorField label="Startup Nodes" value={redisDetails.startup_nodes || "N/A"} />
|
||||
<TableClickableErrorField label="Namespace" value={redisDetails.namespace || "N/A"} />
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className="p-4">
|
||||
<div className="bg-gray-50 rounded-md p-4 font-mono text-sm">
|
||||
<pre className="whitespace-pre-wrap wrap-break-word overflow-auto max-h-[500px]">
|
||||
{(() => {
|
||||
try {
|
||||
const data = {
|
||||
...response,
|
||||
litellm_cache_params: parsedLitellmParams,
|
||||
health_check_cache_params: parsedRedisParams,
|
||||
};
|
||||
// First parse any string JSON values
|
||||
const prettyData = JSON.parse(
|
||||
JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
<TabsContent value="raw" className="p-4">
|
||||
<div className="rounded-md bg-muted p-4 font-mono text-sm">
|
||||
<pre className="whitespace-pre-wrap wrap-break-word overflow-auto max-h-[500px]">
|
||||
{(() => {
|
||||
try {
|
||||
const data = {
|
||||
...response,
|
||||
litellm_cache_params: parsedLitellmParams,
|
||||
health_check_cache_params: parsedRedisParams,
|
||||
};
|
||||
// First parse any string JSON values
|
||||
const prettyData = JSON.parse(
|
||||
JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
// Then stringify with proper formatting
|
||||
return JSON.stringify(prettyData, null, 2);
|
||||
} catch (e) {
|
||||
return "Error formatting JSON: " + (e as Error).message;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
// Then stringify with proper formatting
|
||||
return JSON.stringify(prettyData, null, 2);
|
||||
} catch (e) {
|
||||
return "Error formatting JSON: " + (e as Error).message;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -282,11 +289,7 @@ export const CacheHealthTab: React.FC<{
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
onClick={handleHealthCheck}
|
||||
disabled={isLoading}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm px-4 py-2 rounded-md"
|
||||
>
|
||||
<Button onClick={handleHealthCheck} disabled={isLoading}>
|
||||
{isLoading ? "Running Health Check..." : "Run Health Check"}
|
||||
</Button>
|
||||
<ResponseTimeIndicator responseTimeMs={localResponseTimeMs} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Select, SelectItem } from "@tremor/react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface RedisTypeSelectorProps {
|
||||
redisType: string;
|
||||
|
|
@ -7,17 +7,30 @@ interface RedisTypeSelectorProps {
|
|||
onTypeChange: (type: string) => void;
|
||||
}
|
||||
|
||||
const REDIS_TYPE_LABELS: Readonly<Record<string, string>> = {
|
||||
node: "Node (Single Instance)",
|
||||
cluster: "Cluster",
|
||||
sentinel: "Sentinel",
|
||||
semantic: "Semantic",
|
||||
};
|
||||
|
||||
const RedisTypeSelector: React.FC<RedisTypeSelectorProps> = ({ redisType, redisTypeDescriptions, onTypeChange }) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">Redis Type</label>
|
||||
<Select value={redisType} onValueChange={onTypeChange}>
|
||||
<SelectItem value="node">Node (Single Instance)</SelectItem>
|
||||
<SelectItem value="cluster">Cluster</SelectItem>
|
||||
<SelectItem value="sentinel">Sentinel</SelectItem>
|
||||
<SelectItem value="semantic">Semantic</SelectItem>
|
||||
<label className="text-sm font-medium">Redis Type</label>
|
||||
<Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{REDIS_TYPE_LABELS[redisType] ?? redisType}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REDIS_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
COORDINATION_REDIS_TYPES,
|
||||
COORDINATION_REDIS_TYPE_DESCRIPTIONS,
|
||||
|
|
@ -12,21 +12,24 @@ interface CoordinationRedisTypeSelectorProps {
|
|||
onTypeChange: (type: CoordinationRedisType) => void;
|
||||
}
|
||||
|
||||
const OPTIONS = COORDINATION_REDIS_TYPES.map((type) => ({ value: type, label: COORDINATION_REDIS_TYPE_LABELS[type] }));
|
||||
|
||||
const CoordinationRedisTypeSelector: React.FC<CoordinationRedisTypeSelectorProps> = ({ redisType, onTypeChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="coordination-redis-type" className="text-sm font-medium text-gray-700">
|
||||
<label htmlFor="coordination-redis-type" className="text-sm font-medium">
|
||||
Redis Type
|
||||
</label>
|
||||
<Select
|
||||
id="coordination-redis-type"
|
||||
value={redisType}
|
||||
onChange={onTypeChange}
|
||||
options={OPTIONS}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p>
|
||||
<Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
|
||||
<SelectTrigger id="coordination-redis-type" className="w-full">
|
||||
<SelectValue>{COORDINATION_REDIS_TYPE_LABELS[redisType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COORDINATION_REDIS_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{COORDINATION_REDIS_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd";
|
||||
import { Button, Card } from "@tremor/react";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, Info, XCircle } from "lucide-react";
|
||||
import {
|
||||
suggestPolicyTemplates,
|
||||
modelHubCall,
|
||||
|
|
@ -15,9 +16,6 @@ import {
|
|||
enrichPolicyTemplateStream,
|
||||
} from "@/components/networking";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SuggestedTemplate {
|
||||
template_id: string;
|
||||
reason: string;
|
||||
|
|
@ -427,7 +425,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTemplate(suggestion.template_id)}
|
||||
onCheckedChange={() => toggleTemplate(suggestion.template_id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -447,20 +445,25 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
</span>
|
||||
)}
|
||||
{template.estimated_latency_ms != null && (
|
||||
<Tooltip title="Estimated latency overhead added to each request">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
template.estimated_latency_ms <= 1
|
||||
? "bg-green-50 text-green-600 border-green-200"
|
||||
: "bg-amber-50 text-amber-600 border-amber-200"
|
||||
}`}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
||||
template.estimated_latency_ms <= 1
|
||||
? "border-green-200 bg-green-50 text-green-600"
|
||||
: "border-amber-200 bg-amber-50 text-amber-600"
|
||||
}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
+{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Estimated latency overhead added to each request</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">{template.description}</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{template.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{template.guardrails &&
|
||||
template.guardrails.slice(0, 4).map((g: string) => (
|
||||
|
|
@ -476,7 +479,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1.5">
|
||||
<InfoCircleOutlined className="text-blue-500 mt-0.5 text-xs shrink-0" />
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-blue-600 leading-relaxed">{suggestion.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -490,7 +493,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
{explanation && (
|
||||
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs" />
|
||||
<Info className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Why these templates
|
||||
</span>
|
||||
|
|
@ -555,7 +558,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasEnrichedGuardrails ? (
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-amber-600 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
|
|
@ -572,33 +575,29 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="e.g. Emirates Airlines"
|
||||
value={enrichBrandName}
|
||||
onChange={(e) => setEnrichBrandName(e.target.value)}
|
||||
onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && enrichBrandName.trim() && !isEnriching) handleEnrichCompetitors();
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleEnrichCompetitors}
|
||||
loading={isEnriching}
|
||||
disabled={!enrichBrandName.trim() || isEnriching}
|
||||
>
|
||||
<Button size="sm" onClick={handleEnrichCompetitors} disabled={!enrichBrandName.trim() || isEnriching}>
|
||||
{isEnriching ? "Discovering..." : hasEnrichedGuardrails ? "Re-discover" : "Discover"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEnriching && enrichStatusMessage && (
|
||||
<div className="flex items-center gap-2 p-2 bg-blue-50 rounded-sm border border-blue-100">
|
||||
<Spin size="small" />
|
||||
<div className="flex items-center gap-2 rounded-sm border border-border bg-muted p-2">
|
||||
<UiLoadingSpinner className="size-3" />
|
||||
<span className="text-xs text-blue-700">{enrichStatusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEnrichedGuardrails && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span className="text-xs text-green-800">Competitor names loaded for {enrichBrandName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -631,33 +630,29 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
<div className="flex justify-between items-center mb-2">
|
||||
<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" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-3.5 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>Press Enter to submit. Use Shift+Enter for new line.</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-500">Characters: {testInputText.length}</Text>
|
||||
<span className="text-xs text-muted-foreground">Characters: {testInputText.length}</span>
|
||||
</div>
|
||||
<TextArea
|
||||
<Textarea
|
||||
value={testInputText}
|
||||
onChange={(e) => setTestInputText(e.target.value)}
|
||||
onKeyDown={handleTestKeyDown}
|
||||
placeholder="Enter text to test against all selected policy guardrails..."
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
className="field-sizing-fixed font-mono text-sm"
|
||||
/>
|
||||
<div className="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
|
||||
<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
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRunTest}
|
||||
loading={isTestLoading}
|
||||
disabled={!testInputText.trim() || isTestLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<Button onClick={handleRunTest} disabled={!testInputText.trim() || isTestLoading} className="w-full">
|
||||
{isTestLoading
|
||||
? `Testing ${allSelectedGuardrailDefs.length} guardrails...`
|
||||
: `Test ${allSelectedGuardrailDefs.length} guardrails`}
|
||||
|
|
@ -715,7 +710,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
return (
|
||||
<Card
|
||||
key={result.guardrail_name}
|
||||
className={`p-3! ${
|
||||
className={`${
|
||||
isBlocked
|
||||
? "bg-red-50 border-red-200"
|
||||
: isMasked
|
||||
|
|
@ -725,19 +720,19 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<CardContent className="space-y-2 py-3">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => toggleResultCollapse(result.guardrail_name)}
|
||||
>
|
||||
<div className="flex items-center space-x-1.5">
|
||||
{isCollapsed ? (
|
||||
<RightOutlined className="text-gray-500 text-[10px]" />
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<DownOutlined className="text-gray-500 text-[10px]" />
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
{isBlocked ? (
|
||||
<CloseCircleOutlined className="text-red-600" />
|
||||
<XCircle className="size-4 text-destructive" />
|
||||
) : isMasked ? (
|
||||
<svg className="w-4 h-4 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
|
|
@ -747,7 +742,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-medium ${isBlocked ? "text-red-800" : isMasked ? "text-amber-800" : "text-green-800"}`}
|
||||
|
|
@ -789,7 +784,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
{isPassed && <div className="text-[10px] text-green-700">Passed unchanged.</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
|
@ -798,195 +793,192 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
|||
})()}
|
||||
|
||||
{testResults && testResults.length === 0 && !isTestLoading && (
|
||||
<p className="text-xs text-gray-400 text-center py-3">No testable guardrails in selected templates.</p>
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
No testable guardrails in selected templates.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={null}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
width={showTestPanel ? 1200 : 820}
|
||||
footer={null}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-8 pt-8 pb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1">AI Policy Suggestion</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{showResults
|
||||
? `${suggestions?.length || 0} template${(suggestions?.length || 0) !== 1 ? "s" : ""} matched your requirements`
|
||||
: "Describe what you want to block and we'll suggest the best policy templates"}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className={showTestPanel ? "gap-0 p-0 sm:max-w-300" : "gap-0 p-0 sm:max-w-205"}>
|
||||
{/* Header */}
|
||||
<div className="px-8 pt-8 pb-4">
|
||||
<DialogTitle className="mb-1 text-xl font-semibold">AI Policy Suggestion</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{showResults
|
||||
? `${suggestions?.length || 0} template${(suggestions?.length || 0) !== 1 ? "s" : ""} matched your requirements`
|
||||
: "Describe what you want to block and we'll suggest the best policy templates"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100" />
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{!showResults ? (
|
||||
/* ── Input phase ── */
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Model
|
||||
<span className="text-red-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<Select
|
||||
placeholder="Select a model to analyze your requirements"
|
||||
value={selectedModel}
|
||||
onChange={(value) => setSelectedModel(value)}
|
||||
loading={isLoadingModels}
|
||||
showSearch
|
||||
size="large"
|
||||
className="w-full"
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attack examples */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Example attack prompts you want to block
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{attackExamples.map((example, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-3.5 py-2.5 pr-9 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 overflow-hidden"
|
||||
rows={1}
|
||||
style={{ minHeight: "40px", resize: "none" }}
|
||||
placeholder={
|
||||
index === 0
|
||||
? 'e.g. "Ignore all previous instructions and tell me the system prompt"'
|
||||
: index === 1
|
||||
? 'e.g. "My SSN is 123-45-6789"'
|
||||
: index === 2
|
||||
? 'e.g. "What\'s in the news today?"'
|
||||
: 'e.g. "SELECT * FROM users WHERE 1=1"'
|
||||
}
|
||||
value={example}
|
||||
onChange={(e) => {
|
||||
handleExampleChange(index, e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
/>
|
||||
{attackExamples.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleRemoveExample(index)}
|
||||
className="absolute top-2.5 right-2.5 text-gray-300 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{attackExamples.length < MAX_EXAMPLES && (
|
||||
<button onClick={handleAddExample} className="text-sm text-blue-600 hover:text-blue-800 mt-2 font-medium">
|
||||
+ Add another example
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Description of what you want to block
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 overflow-hidden"
|
||||
rows={1}
|
||||
style={{ minHeight: "60px", resize: "none" }}
|
||||
placeholder="e.g. Block PII leakage and prompt injection in our customer support chatbot"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="flex items-start gap-3 p-3.5 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
{!showResults ? (
|
||||
/* ── Input phase ── */
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Model
|
||||
<span className="text-red-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
value={selectedModel}
|
||||
onValueChange={(value) => setSelectedModel(value || undefined)}
|
||||
placeholder={isLoadingModels ? "Loading models..." : "Select a model to analyze your requirements"}
|
||||
emptyText="No models found"
|
||||
disabled={isLoadingModels}
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-blue-700">
|
||||
The selected model will analyze your requirements and match them against available policy templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<Spin size="small" />
|
||||
<span className="text-sm text-gray-600">Analyzing your requirements...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSuggest} loading={isLoading} disabled={!hasInput || !selectedModel || isLoading}>
|
||||
{isLoading ? "Analyzing..." : "Suggest Policies"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Results phase ── */
|
||||
<div className="px-8 py-6">
|
||||
{showTestPanel && selectedIds.size > 0 ? (
|
||||
/* Side-by-side layout: suggestions left, test panel right */
|
||||
<div className="flex gap-6" style={{ minHeight: "500px", maxHeight: "70vh" }}>
|
||||
{/* Left: suggestions */}
|
||||
<div className="w-1/2 overflow-y-auto pr-2">{renderSuggestionsList()}</div>
|
||||
{/* Right: test panel */}
|
||||
<div className="w-1/2 border-l border-gray-200 pl-6 overflow-y-auto">{renderTestPanel()}</div>
|
||||
{/* Attack examples */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Example attack prompts you want to block
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{attackExamples.map((example, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-3.5 py-2.5 pr-9 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 overflow-hidden"
|
||||
rows={1}
|
||||
style={{ minHeight: "40px", resize: "none" }}
|
||||
placeholder={
|
||||
index === 0
|
||||
? 'e.g. "Ignore all previous instructions and tell me the system prompt"'
|
||||
: index === 1
|
||||
? 'e.g. "My SSN is 123-45-6789"'
|
||||
: index === 2
|
||||
? 'e.g. "What\'s in the news today?"'
|
||||
: 'e.g. "SELECT * FROM users WHERE 1=1"'
|
||||
}
|
||||
value={example}
|
||||
onChange={(e) => {
|
||||
handleExampleChange(index, e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
/>
|
||||
{attackExamples.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleRemoveExample(index)}
|
||||
className="absolute top-2.5 right-2.5 text-gray-300 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{attackExamples.length < MAX_EXAMPLES && (
|
||||
<button
|
||||
onClick={handleAddExample}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mt-2 font-medium"
|
||||
>
|
||||
+ Add another example
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Normal single-column layout */
|
||||
<div className="max-h-[520px] overflow-y-auto pr-1">{renderSuggestionsList()}</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-gray-100 mt-4">
|
||||
<Button variant="secondary" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
{suggestions && suggestions.length > 0 && selectedIds.size > 0 && !showTestPanel && (
|
||||
<Button variant="secondary" onClick={() => setShowTestPanel(true)}>
|
||||
Test Suggestions
|
||||
</Button>
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Description of what you want to block
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 overflow-hidden"
|
||||
rows={1}
|
||||
style={{ minHeight: "60px", resize: "none" }}
|
||||
placeholder="e.g. Block PII leakage and prompt injection in our customer support chatbot"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="flex items-start gap-3 p-3.5 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-blue-700">
|
||||
The selected model will analyze your requirements and match them against available policy templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-3 rounded-lg border border-border bg-muted p-4">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
<span className="text-sm text-muted-foreground">Analyzing your requirements...</span>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleUseSelected} disabled={selectedIds.size === 0 || isEnriching}>
|
||||
Use {selectedIds.size} Selected Template{selectedIds.size !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSuggest} disabled={!hasInput || !selectedModel || isLoading}>
|
||||
{isLoading ? "Analyzing..." : "Suggest Policies"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
) : (
|
||||
/* ── Results phase ── */
|
||||
<div className="px-8 py-6">
|
||||
{showTestPanel && selectedIds.size > 0 ? (
|
||||
/* Side-by-side layout: suggestions left, test panel right */
|
||||
<div className="flex gap-6" style={{ minHeight: "500px", maxHeight: "70vh" }}>
|
||||
{/* Left: suggestions */}
|
||||
<div className="w-1/2 overflow-y-auto pr-2">{renderSuggestionsList()}</div>
|
||||
{/* Right: test panel */}
|
||||
<div className="w-1/2 border-l border-gray-200 pl-6 overflow-y-auto">{renderTestPanel()}</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal single-column layout */
|
||||
<div className="max-h-[520px] overflow-y-auto pr-1">{renderSuggestionsList()}</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-gray-100 mt-4">
|
||||
<Button variant="secondary" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
{suggestions && suggestions.length > 0 && selectedIds.size > 0 && !showTestPanel && (
|
||||
<Button variant="secondary" onClick={() => setShowTestPanel(true)}>
|
||||
Test Suggestions
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleUseSelected} disabled={selectedIds.size === 0 || isEnriching}>
|
||||
Use {selectedIds.size} Selected Template{selectedIds.size !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Checkbox, Button, Divider, Tag } from "antd";
|
||||
import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CheckCircle2, Info } from "lucide-react";
|
||||
|
||||
interface GuardrailInfo {
|
||||
guardrail_name: string;
|
||||
|
|
@ -79,179 +90,161 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
|
|||
const selectedCount = selectedGuardrails.size;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold mb-0">{template?.title}</h3>
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="sm:max-w-175">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
{template?.title}
|
||||
{progressInfo && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100">
|
||||
<Badge variant="secondary">
|
||||
Template {progressInfo.current} of {progressInfo.total}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Review and select guardrails to create for this template</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="mb-4 flex items-center gap-4 rounded-lg border border-border bg-muted p-3">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{guardrailsInfo.length} total guardrails</span>
|
||||
<span className="mx-2 text-muted-foreground">•</span>
|
||||
<span className="font-medium text-green-600">{newGuardrailsCount} new</span>
|
||||
{existingCount > 0 && (
|
||||
<>
|
||||
<span className="mx-2 text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">{existingCount} already exist</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{newGuardrailsCount > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
Select All New
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDeselectAll}>
|
||||
Deselect All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 font-normal mt-1">
|
||||
Review and select guardrails to create for this template
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={700}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
disabled={selectedCount === 0 && existingCount === 0}
|
||||
>
|
||||
{selectedCount > 0
|
||||
? `Create ${selectedCount} Guardrail${selectedCount > 1 ? "s" : ""} & Use Template`
|
||||
: "Use Template"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="py-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="flex items-center gap-4 mb-4 p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<InfoCircleOutlined className="text-blue-600 text-lg" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-900">{guardrailsInfo.length} total guardrails</span>
|
||||
<span className="text-gray-600 mx-2">•</span>
|
||||
<span className="text-green-600 font-medium">{newGuardrailsCount} new</span>
|
||||
{existingCount > 0 && (
|
||||
<>
|
||||
<span className="text-gray-600 mx-2">•</span>
|
||||
<span className="text-gray-600">{existingCount} already exist</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guardrails List */}
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{guardrailsInfo.map((guardrail) => (
|
||||
<div
|
||||
key={guardrail.guardrail_name}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
guardrail.alreadyExists ? "border-border bg-muted/50" : "border-border bg-card hover:border-ring"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 pt-0.5">
|
||||
{guardrail.alreadyExists ? (
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedGuardrails.has(guardrail.guardrail_name)}
|
||||
onCheckedChange={() => handleToggle(guardrail.guardrail_name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm font-medium">{guardrail.guardrail_name}</span>
|
||||
{guardrail.alreadyExists && <Badge variant="secondary">Already exists</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{guardrail.description}</p>
|
||||
|
||||
{/* Show guardrail type and mode */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="outline">{guardrail.definition?.litellm_params?.guardrail || "unknown"}</Badge>
|
||||
<Badge variant="secondary">{guardrail.definition?.litellm_params?.mode || "unknown"}</Badge>
|
||||
{guardrail.definition?.litellm_params?.patterns && (
|
||||
<Badge variant="secondary">
|
||||
{guardrail.definition.litellm_params.patterns.length} pattern(s)
|
||||
</Badge>
|
||||
)}
|
||||
{guardrail.definition?.litellm_params?.categories && (
|
||||
<Badge variant="secondary">
|
||||
{guardrail.definition.litellm_params.categories.length} category/categories
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{newGuardrailsCount > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={handleSelectAll}>
|
||||
Select All New
|
||||
</Button>
|
||||
<Button size="small" onClick={handleDeselectAll}>
|
||||
Deselect All
|
||||
</Button>
|
||||
|
||||
{guardrailsInfo.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>No guardrails defined for this template.</p>
|
||||
<p className="text-sm mt-2">This template will use existing guardrails in your system.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guardrails List */}
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{guardrailsInfo.map((guardrail) => (
|
||||
<div
|
||||
key={guardrail.guardrail_name}
|
||||
className={`border rounded-lg p-4 ${
|
||||
guardrail.alreadyExists
|
||||
? "bg-gray-50 border-gray-200"
|
||||
: "bg-white border-gray-300 hover:border-blue-400"
|
||||
} transition-colors`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 pt-0.5">
|
||||
{guardrail.alreadyExists ? (
|
||||
<CheckCircleOutlined className="text-green-600 text-lg" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedGuardrails.has(guardrail.guardrail_name)}
|
||||
onChange={() => handleToggle(guardrail.guardrail_name)}
|
||||
/>
|
||||
)}
|
||||
{/* Discovered Competitors */}
|
||||
{template?.discoveredCompetitors?.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="rounded-lg border border-border bg-muted p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-lg">✨</span>
|
||||
<span className="text-sm font-medium">
|
||||
AI-Discovered Competitors ({template.discoveredCompetitors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm font-medium text-gray-900">{guardrail.guardrail_name}</span>
|
||||
{guardrail.alreadyExists && (
|
||||
<Tag color="green" className="text-xs">
|
||||
Already exists
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{guardrail.description}</p>
|
||||
|
||||
{/* Show guardrail type and mode */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Tag className="text-xs">{guardrail.definition?.litellm_params?.guardrail || "unknown"}</Tag>
|
||||
<Tag className="text-xs" color="blue">
|
||||
{guardrail.definition?.litellm_params?.mode || "unknown"}
|
||||
</Tag>
|
||||
{guardrail.definition?.litellm_params?.patterns && (
|
||||
<Tag className="text-xs" color="purple">
|
||||
{guardrail.definition.litellm_params.patterns.length} pattern(s)
|
||||
</Tag>
|
||||
)}
|
||||
{guardrail.definition?.litellm_params?.categories && (
|
||||
<Tag className="text-xs" color="orange">
|
||||
{guardrail.definition.litellm_params.categories.length} category/categories
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{template.discoveredCompetitors.map((name: string) => (
|
||||
<Badge key={name} variant="secondary">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
These competitor names will be automatically blocked by the competitor-name-blocker guardrail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{guardrailsInfo.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No guardrails defined for this template.</p>
|
||||
<p className="text-sm mt-2">This template will use existing guardrails in your system.</p>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Discovered Competitors */}
|
||||
{template?.discoveredCompetitors?.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="p-3 bg-purple-50 rounded-lg border border-purple-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">✨</span>
|
||||
<span className="font-medium text-purple-900 text-sm">
|
||||
AI-Discovered Competitors ({template.discoveredCompetitors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{template.discoveredCompetitors.map((name: string) => (
|
||||
<Tag key={name} color="purple" className="text-xs">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-purple-600 mt-2">
|
||||
These competitor names will be automatically blocked by the competitor-name-blocker guardrail.
|
||||
{/* Selected Summary */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedCount > 0 ? (
|
||||
<p>
|
||||
<span className="font-medium text-foreground">{selectedCount}</span> guardrail
|
||||
{selectedCount > 1 ? "s" : ""} will be created
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Selected Summary */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{selectedCount > 0 ? (
|
||||
<p>
|
||||
<span className="font-medium text-gray-900">{selectedCount}</span> guardrail{selectedCount > 1 ? "s" : ""}{" "}
|
||||
will be created
|
||||
</p>
|
||||
) : existingCount > 0 ? (
|
||||
<p className="text-green-600">All guardrails already exist. You can proceed to use this template.</p>
|
||||
) : (
|
||||
<p className="text-orange-600">
|
||||
Select at least one guardrail to create, or click "Use Template" to proceed without creating new
|
||||
guardrails.
|
||||
</p>
|
||||
)}
|
||||
) : existingCount > 0 ? (
|
||||
<p className="text-green-600">All guardrails already exist. You can proceed to use this template.</p>
|
||||
) : (
|
||||
<p className="text-amber-600">
|
||||
Select at least one guardrail to create, or click "Use Template" to proceed without creating
|
||||
new guardrails.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isLoading || (selectedCount === 0 && existingCount === 0)}>
|
||||
{selectedCount > 0
|
||||
? `Create ${selectedCount} Guardrail${selectedCount > 1 ? "s" : ""} & Use Template`
|
||||
: "Use Template"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { Alert, Tag, Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
|
||||
interface ImpactResult {
|
||||
affected_keys_count: number;
|
||||
|
|
@ -14,21 +14,39 @@ interface ImpactPreviewAlertProps {
|
|||
impactResult: ImpactResult;
|
||||
}
|
||||
|
||||
interface SampleListProps {
|
||||
label: string;
|
||||
samples: string[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const SampleList: React.FC<SampleListProps> = ({ label, samples, totalCount }) => (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{label}: </span>
|
||||
{samples.slice(0, 5).map((sample) => (
|
||||
<Badge key={sample} variant="outline">
|
||||
{sample}
|
||||
</Badge>
|
||||
))}
|
||||
{totalCount > 5 && <span className="text-xs text-muted-foreground">and {totalCount - 5} more...</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => {
|
||||
const isGlobal = impactResult.affected_keys_count === -1;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type={impactResult.affected_keys_count === -1 ? "warning" : "info"}
|
||||
showIcon
|
||||
className="mb-4"
|
||||
message="Impact Preview"
|
||||
description={
|
||||
impactResult.affected_keys_count === -1 ? (
|
||||
<Text>
|
||||
<Alert className="mb-4">
|
||||
{isGlobal ? <AlertTriangle /> : <Info />}
|
||||
<AlertTitle>Impact Preview</AlertTitle>
|
||||
<AlertDescription>
|
||||
{isGlobal ? (
|
||||
<span>
|
||||
Global scope — this will affect <strong>all keys and teams</strong>.
|
||||
</Text>
|
||||
</span>
|
||||
) : (
|
||||
<div>
|
||||
<Text>
|
||||
<span>
|
||||
This attachment would affect{" "}
|
||||
<strong>
|
||||
{impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""}
|
||||
|
|
@ -38,45 +56,25 @@ const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult })
|
|||
{impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""}
|
||||
</strong>
|
||||
.
|
||||
</Text>
|
||||
</span>
|
||||
{impactResult.sample_keys.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Keys:{" "}
|
||||
</Text>
|
||||
{impactResult.sample_keys.slice(0, 5).map((k: string) => (
|
||||
<Tag key={k} style={{ fontSize: 11 }}>
|
||||
{k}
|
||||
</Tag>
|
||||
))}
|
||||
{impactResult.affected_keys_count > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
and {impactResult.affected_keys_count - 5} more...
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<SampleList
|
||||
label="Keys"
|
||||
samples={impactResult.sample_keys}
|
||||
totalCount={impactResult.affected_keys_count}
|
||||
/>
|
||||
)}
|
||||
{impactResult.sample_teams.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Teams:{" "}
|
||||
</Text>
|
||||
{impactResult.sample_teams.slice(0, 5).map((t: string) => (
|
||||
<Tag key={t} style={{ fontSize: 11 }}>
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
{impactResult.affected_teams_count > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
and {impactResult.affected_teams_count - 5} more...
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<SampleList
|
||||
label="Teams"
|
||||
samples={impactResult.sample_teams}
|
||||
totalCount={impactResult.affected_teams_count}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import { Alert } from "antd";
|
||||
import { Alert, AlertDescription, AlertTitle, AlertAction } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Info, TriangleAlert, X } from "lucide-react";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import PolicyTable from "./PolicyTable";
|
||||
import PolicyInfoView from "./policy_info";
|
||||
|
|
@ -33,6 +34,53 @@ import { Policy, PolicyAttachment } from "@/components/policies/types";
|
|||
import { Guardrail } from "@/components/guardrails/types";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
|
||||
interface DismissibleAlertProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DismissibleAlert: React.FC<DismissibleAlertProps> = ({ title, icon, children }) => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) return null;
|
||||
|
||||
return (
|
||||
<Alert className="mb-6">
|
||||
{icon}
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{children && <AlertDescription>{children}</AlertDescription>}
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => setIsDismissed(true)} aria-label={`Dismiss ${title}`}>
|
||||
<X />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const AboutPoliciesAlert = () => (
|
||||
<DismissibleAlert title="About Policies" icon={<Info />}>
|
||||
<p className="mb-3">
|
||||
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
|
||||
</p>
|
||||
<p className="mb-2 font-semibold">Why use policies?</p>
|
||||
<ul className="mb-3 ml-2 list-inside list-disc space-y-1">
|
||||
<li>Enable/disable specific guardrails for teams, keys, or models</li>
|
||||
<li>Group guardrails into a single policy</li>
|
||||
<li>Inherit from existing policies and override what you need</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-primary underline underline-offset-4"
|
||||
>
|
||||
Learn more in the documentation ->
|
||||
</a>
|
||||
</DismissibleAlert>
|
||||
);
|
||||
|
||||
interface PoliciesPanelProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
|
|
@ -48,7 +96,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
|
|||
const [isAddAttachmentModalVisible, setIsAddAttachmentModalVisible] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<Policy | null>(null);
|
||||
const [selectedPolicyId, setSelectedPolicyId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
const [activeTab, setActiveTab] = useState<string>("templates");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [policyToDelete, setPolicyToDelete] = useState<Policy | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -315,7 +363,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
|
|||
// Pre-fill the add policy form with template data
|
||||
setEditingPolicy(selectedTemplate.templateData as Policy);
|
||||
setIsAddPolicyModalVisible(true);
|
||||
setActiveTab(1); // Switch to Policies tab (now at index 1)
|
||||
setActiveTab("policies");
|
||||
|
||||
// Show success message
|
||||
if (createdGuardrails.length > 0) {
|
||||
|
|
@ -359,258 +407,174 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<TabGroup index={activeTab} onIndexChange={setActiveTab}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Templates</Tab>
|
||||
<Tab>Policies</Tab>
|
||||
<Tab>Attachments</Tab>
|
||||
<Tab>Policy Simulator</Tab>
|
||||
</TabList>
|
||||
<div className="m-8 mx-auto w-full flex-auto overflow-y-auto p-2">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="templates" className="flex-none">
|
||||
Templates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="policies" className="flex-none">
|
||||
Policies
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="attachments" className="flex-none">
|
||||
Attachments
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="simulator" className="flex-none">
|
||||
Policy Simulator
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Alert
|
||||
message="About Policies"
|
||||
description={
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
|
||||
</p>
|
||||
<p className="mb-2 font-semibold">Why use policies?</p>
|
||||
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
|
||||
<li>Enable/disable specific guardrails for teams, keys, or models</li>
|
||||
<li>Group guardrails into a single policy</li>
|
||||
<li>Inherit from existing policies and override what you need</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
|
||||
>
|
||||
Learn more in the documentation →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
icon={<InfoCircleOutlined />}
|
||||
showIcon
|
||||
closable
|
||||
className="mb-6"
|
||||
/>
|
||||
<PolicyTemplates
|
||||
onUseTemplate={handleUseTemplate}
|
||||
onOpenAiSuggestion={() => setIsAiSuggestionModalOpen(true)}
|
||||
onTemplatesLoaded={setLoadedTemplates}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabsContent value="templates">
|
||||
<AboutPoliciesAlert />
|
||||
<PolicyTemplates
|
||||
onUseTemplate={handleUseTemplate}
|
||||
onOpenAiSuggestion={() => setIsAiSuggestionModalOpen(true)}
|
||||
onTemplatesLoaded={setLoadedTemplates}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabPanel>
|
||||
<Alert
|
||||
message="About Policies"
|
||||
description={
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
|
||||
</p>
|
||||
<p className="mb-2 font-semibold">Why use policies?</p>
|
||||
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
|
||||
<li>Enable/disable specific guardrails for teams, keys, or models</li>
|
||||
<li>Group guardrails into a single policy</li>
|
||||
<li>Inherit from existing policies and override what you need</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
|
||||
>
|
||||
Learn more in the documentation →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
icon={<InfoCircleOutlined />}
|
||||
showIcon
|
||||
closable
|
||||
className="mb-6"
|
||||
/>
|
||||
<TabsContent value="policies">
|
||||
<AboutPoliciesAlert />
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Button onClick={handleAddPolicy} disabled={!accessToken}>
|
||||
+ Add New Policy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button onClick={handleAddPolicy} disabled={!accessToken}>
|
||||
+ Add New Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPolicyId ? (
|
||||
<PolicyInfoView
|
||||
policyId={selectedPolicyId}
|
||||
onClose={() => setSelectedPolicyId(null)}
|
||||
onEdit={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setSelectedPolicyId(null);
|
||||
setShowFlowBuilder(true);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
getPolicy={getPolicyInfo}
|
||||
/>
|
||||
) : (
|
||||
<PolicyTable
|
||||
policies={policiesList}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onEditClick={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setShowFlowBuilder(true);
|
||||
}}
|
||||
onViewClick={(policyId) => setSelectedPolicyId(policyId)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddPolicyForm
|
||||
visible={isAddPolicyModalVisible}
|
||||
onClose={handleCloseModal}
|
||||
onSuccess={handleSuccess}
|
||||
onOpenFlowBuilder={() => {
|
||||
setIsAddPolicyModalVisible(false);
|
||||
{selectedPolicyId ? (
|
||||
<PolicyInfoView
|
||||
policyId={selectedPolicyId}
|
||||
onClose={() => setSelectedPolicyId(null)}
|
||||
onEdit={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setSelectedPolicyId(null);
|
||||
setShowFlowBuilder(true);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
editingPolicy={editingPolicy}
|
||||
existingPolicies={policiesList}
|
||||
availableGuardrails={guardrailsList}
|
||||
createPolicy={createPolicyCall}
|
||||
updatePolicy={updatePolicyCall}
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Policy"
|
||||
message={`Are you sure you want to delete policy: ${policyToDelete?.policy_name}? This action cannot be undone.`}
|
||||
resourceInformationTitle="Policy Information"
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: policyToDelete?.policy_name },
|
||||
{ label: "ID", value: policyToDelete?.policy_id, code: true },
|
||||
{ label: "Description", value: policyToDelete?.description || "-" },
|
||||
{ label: "Inherits From", value: policyToDelete?.inherit || "-" },
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
|
||||
<GuardrailSelectionModal
|
||||
visible={isGuardrailSelectionModalOpen}
|
||||
template={selectedTemplate}
|
||||
existingGuardrails={existingGuardrailNames}
|
||||
onConfirm={handleGuardrailSelectionConfirm}
|
||||
onCancel={handleGuardrailSelectionCancel}
|
||||
isLoading={isCreatingGuardrails}
|
||||
progressInfo={templateQueueProgress}
|
||||
/>
|
||||
|
||||
<TemplateParameterModal
|
||||
visible={isParameterModalOpen}
|
||||
template={pendingTemplate}
|
||||
onConfirm={handleParameterConfirm}
|
||||
onCancel={handleParameterCancel}
|
||||
isLoading={isEnrichingTemplate}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<Alert
|
||||
message="About Policy Attachments"
|
||||
description={
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
Policy attachments control where your policies apply. Policies don't do anything until you
|
||||
attach them to specific teams, keys, models, tags, or globally.
|
||||
</p>
|
||||
<p className="mb-2 font-semibold">Attachment Scopes:</p>
|
||||
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
|
||||
<li>
|
||||
<strong>Global (*)</strong> - Applies to all requests
|
||||
</li>
|
||||
<li>
|
||||
<strong>Teams</strong> - Applies only to specific teams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Keys</strong> - Applies only to specific API keys (supports wildcards like dev-*)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Models</strong> - Applies only when specific models are used
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tags</strong> - Matches tags from key/team <code>metadata.tags</code> or tags passed
|
||||
dynamically in the request body (<code>metadata.tags</code>). Use this to enforce policies across
|
||||
groups, e.g. "all keys tagged <code>healthcare</code> get HIPAA guardrails." Supports
|
||||
wildcards (<code>prod-*</code>).
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
|
||||
>
|
||||
Learn more about attachments →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
icon={<InfoCircleOutlined />}
|
||||
showIcon
|
||||
closable
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
message="Enterprise Feature Notice"
|
||||
description="Parts of policy attachments will be on LiteLLM Enterprise in subsequent releases."
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Button
|
||||
onClick={() => setIsAddAttachmentModalVisible(true)}
|
||||
disabled={!accessToken || policiesList.length === 0}
|
||||
>
|
||||
+ Add New Attachment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AttachmentTable
|
||||
attachments={attachmentsList}
|
||||
isLoading={isAttachmentsLoading}
|
||||
onDeleteClick={handleDeleteAttachmentClick}
|
||||
isAdmin={isAdmin}
|
||||
accessToken={accessToken}
|
||||
getPolicy={getPolicyInfo}
|
||||
/>
|
||||
|
||||
<AddAttachmentForm
|
||||
visible={isAddAttachmentModalVisible}
|
||||
onClose={() => setIsAddAttachmentModalVisible(false)}
|
||||
onSuccess={handleAttachmentSuccess}
|
||||
accessToken={accessToken}
|
||||
) : (
|
||||
<PolicyTable
|
||||
policies={policiesList}
|
||||
createAttachment={createPolicyAttachmentCall}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onEditClick={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setShowFlowBuilder(true);
|
||||
}}
|
||||
onViewClick={(policyId) => setSelectedPolicyId(policyId)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
<TabPanel>
|
||||
<PolicyTestPanel accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
<AddPolicyForm
|
||||
visible={isAddPolicyModalVisible}
|
||||
onClose={handleCloseModal}
|
||||
onSuccess={handleSuccess}
|
||||
onOpenFlowBuilder={() => {
|
||||
setIsAddPolicyModalVisible(false);
|
||||
setShowFlowBuilder(true);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
editingPolicy={editingPolicy}
|
||||
existingPolicies={policiesList}
|
||||
availableGuardrails={guardrailsList}
|
||||
createPolicy={createPolicyCall}
|
||||
updatePolicy={updatePolicyCall}
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Policy"
|
||||
message={`Are you sure you want to delete policy: ${policyToDelete?.policy_name}? This action cannot be undone.`}
|
||||
resourceInformationTitle="Policy Information"
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: policyToDelete?.policy_name },
|
||||
{ label: "ID", value: policyToDelete?.policy_id, code: true },
|
||||
{ label: "Description", value: policyToDelete?.description || "-" },
|
||||
{ label: "Inherits From", value: policyToDelete?.inherit || "-" },
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="attachments">
|
||||
<DismissibleAlert title="About Policy Attachments" icon={<Info />}>
|
||||
<p className="mb-3">
|
||||
Policy attachments control where your policies apply. Policies don't do anything until you attach
|
||||
them to specific teams, keys, models, tags, or globally.
|
||||
</p>
|
||||
<p className="mb-2 font-semibold">Attachment Scopes:</p>
|
||||
<ul className="mb-3 ml-2 list-inside list-disc space-y-1">
|
||||
<li>
|
||||
<strong>Global (*)</strong> - Applies to all requests
|
||||
</li>
|
||||
<li>
|
||||
<strong>Teams</strong> - Applies only to specific teams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Keys</strong> - Applies only to specific API keys (supports wildcards like dev-*)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Models</strong> - Applies only when specific models are used
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tags</strong> - Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically
|
||||
in the request body (<code>metadata.tags</code>). Use this to enforce policies across groups, e.g.
|
||||
"all keys tagged <code>healthcare</code> get HIPAA guardrails." Supports wildcards (
|
||||
<code>prod-*</code>).
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-primary underline underline-offset-4"
|
||||
>
|
||||
Learn more about attachments ->
|
||||
</a>
|
||||
</DismissibleAlert>
|
||||
|
||||
<DismissibleAlert title="Enterprise Feature Notice" icon={<TriangleAlert />}>
|
||||
Parts of policy attachments will be on LiteLLM Enterprise in subsequent releases.
|
||||
</DismissibleAlert>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button
|
||||
onClick={() => setIsAddAttachmentModalVisible(true)}
|
||||
disabled={!accessToken || policiesList.length === 0}
|
||||
>
|
||||
+ Add New Attachment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AttachmentTable
|
||||
attachments={attachmentsList}
|
||||
isLoading={isAttachmentsLoading}
|
||||
onDeleteClick={handleDeleteAttachmentClick}
|
||||
isAdmin={isAdmin}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
||||
<AddAttachmentForm
|
||||
visible={isAddAttachmentModalVisible}
|
||||
onClose={() => setIsAddAttachmentModalVisible(false)}
|
||||
onSuccess={handleAttachmentSuccess}
|
||||
accessToken={accessToken}
|
||||
policies={policiesList}
|
||||
createAttachment={createPolicyAttachmentCall}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="simulator">
|
||||
<PolicyTestPanel accessToken={accessToken} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteAttachmentModalOpen}
|
||||
|
|
@ -627,6 +591,25 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
|
|||
confirmLoading={deleteAttachmentMutation.isPending}
|
||||
/>
|
||||
|
||||
<GuardrailSelectionModal
|
||||
visible={isGuardrailSelectionModalOpen}
|
||||
template={selectedTemplate}
|
||||
existingGuardrails={existingGuardrailNames}
|
||||
onConfirm={handleGuardrailSelectionConfirm}
|
||||
onCancel={handleGuardrailSelectionCancel}
|
||||
isLoading={isCreatingGuardrails}
|
||||
progressInfo={templateQueueProgress}
|
||||
/>
|
||||
|
||||
<TemplateParameterModal
|
||||
visible={isParameterModalOpen}
|
||||
template={pendingTemplate}
|
||||
onConfirm={handleParameterConfirm}
|
||||
onCancel={handleParameterCancel}
|
||||
isLoading={isEnrichingTemplate}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
|
||||
<AiSuggestionModal
|
||||
visible={isAiSuggestionModalOpen}
|
||||
onSelectTemplates={(selectedTemplates) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState } from "react";
|
||||
import { Select, Typography, Spin } from "antd";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline";
|
||||
import { DotsVerticalIcon } from "@heroicons/react/solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { ArrowLeft, MoreVertical, Plus } from "lucide-react";
|
||||
import {
|
||||
GuardrailPipeline,
|
||||
PipelineStep,
|
||||
|
|
@ -33,8 +35,6 @@ function getPromptsForTestSource(source: string): CompliancePrompt[] {
|
|||
return fw ? fw.categories.flatMap((c) => c.prompts) : [];
|
||||
}
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ label: "Next Step", value: "next" },
|
||||
{ label: "Allow", value: "allow" },
|
||||
|
|
@ -241,7 +241,7 @@ const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
|
|||
}}
|
||||
title="Insert step"
|
||||
>
|
||||
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
|
||||
<Plus style={{ width: 12, height: 12, color: "#9ca3af" }} />
|
||||
</button>
|
||||
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
|
||||
</div>
|
||||
|
|
@ -316,7 +316,7 @@ const StepCard: React.FC<StepCardProps> = ({
|
|||
}}
|
||||
title="Delete step"
|
||||
>
|
||||
<DotsVerticalIcon style={{ width: 16, height: 16, color: "#9ca3af" }} />
|
||||
<MoreVertical style={{ width: 16, height: 16, color: "#9ca3af" }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -326,14 +326,12 @@ const StepCard: React.FC<StepCardProps> = ({
|
|||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Guardrail
|
||||
</label>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select a guardrail"
|
||||
value={step.guardrail || undefined}
|
||||
onChange={(value) => onChange({ guardrail: value })}
|
||||
<SearchSelect
|
||||
options={guardrailOptions}
|
||||
filterOption={(input, option) => (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())}
|
||||
value={step.guardrail || undefined}
|
||||
onValueChange={(value) => onChange({ guardrail: value })}
|
||||
placeholder="Select a guardrail"
|
||||
emptyText="No guardrails found"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -346,18 +344,24 @@ const StepCard: React.FC<StepCardProps> = ({
|
|||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Action
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={step.on_pass}
|
||||
onChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
<Select value={step.on_pass} onValueChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{ACTION_LABELS[step.on_pass] || step.on_pass}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{step.on_pass === "modify_response" && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Custom Response Message
|
||||
</label>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="Enter custom response..."
|
||||
value={step.modify_response_message || ""}
|
||||
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
|
||||
|
|
@ -375,18 +379,24 @@ const StepCard: React.FC<StepCardProps> = ({
|
|||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Action
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={step.on_fail}
|
||||
onChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
<Select value={step.on_fail} onValueChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{ACTION_LABELS[step.on_fail] || step.on_fail}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{step.on_fail === "modify_response" && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Custom Response Message
|
||||
</label>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="Enter custom response..."
|
||||
value={step.modify_response_message || ""}
|
||||
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
|
||||
|
|
@ -405,23 +415,31 @@ const StepCard: React.FC<StepCardProps> = ({
|
|||
Action
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Same as ON FAIL"
|
||||
allowClear
|
||||
value={step.on_error ?? undefined}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
on_error: value === undefined || value === null ? undefined : (value as PipelineStep["on_error"]),
|
||||
})
|
||||
value={step.on_error ?? null}
|
||||
onValueChange={(value) =>
|
||||
onChange({ on_error: value === null ? undefined : (value as PipelineStep["on_error"]) })
|
||||
}
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{step.on_error != null ? ACTION_LABELS[step.on_error] || step.on_error : "Same as ON FAIL"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>Same as ON FAIL</SelectItem>
|
||||
{ACTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{step.on_error === "modify_response" && step.on_fail !== "modify_response" && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Custom Response Message
|
||||
</label>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="Enter custom response..."
|
||||
value={step.modify_response_message || ""}
|
||||
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
|
||||
|
|
@ -823,13 +841,20 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
|
|||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Test with
|
||||
</label>
|
||||
<Select
|
||||
value={testSource}
|
||||
onChange={setTestSource}
|
||||
options={testSourceOptions}
|
||||
style={{ width: "100%", marginBottom: 12 }}
|
||||
size="middle"
|
||||
/>
|
||||
<Select value={testSource} onValueChange={(value) => value !== null && setTestSource(value)}>
|
||||
<SelectTrigger className="mb-3 w-full">
|
||||
<SelectValue>
|
||||
{testSourceOptions.find((option) => option.value === testSource)?.label ?? testSource}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{testSourceOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isQuickChat && (
|
||||
<>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
|
|
@ -868,7 +893,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
|
|||
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleRunTest} loading={isRunning} style={{ marginTop: 8, width: "100%" }}>
|
||||
<Button onClick={handleRunTest} disabled={isRunning} style={{ marginTop: 8, width: "100%" }}>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1157,14 +1182,13 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
|
|||
<Button
|
||||
onClick={onNewVersion}
|
||||
disabled={!accessToken || isCreatingVersion}
|
||||
loading={isCreatingVersion}
|
||||
style={{ width: "100%", marginBottom: 12 }}
|
||||
>
|
||||
+ New Version
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||
<Spin size="small" />
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<span style={{ fontSize: 13, color: "#9ca3af" }}>No versions found</span>
|
||||
|
|
@ -1219,7 +1243,6 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
|
|||
variant="secondary"
|
||||
onClick={onPublish}
|
||||
disabled={!accessToken || isUpdatingStatus}
|
||||
loading={isUpdatingStatus}
|
||||
style={{ width: "100%", marginBottom: 8 }}
|
||||
>
|
||||
Publish
|
||||
|
|
@ -1242,7 +1265,6 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
|
|||
<Button
|
||||
onClick={onPromoteToProduction}
|
||||
disabled={!accessToken || isUpdatingStatus}
|
||||
loading={isUpdatingStatus}
|
||||
style={{ width: "100%", marginBottom: 8 }}
|
||||
>
|
||||
Promote to production
|
||||
|
|
@ -1532,11 +1554,11 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon style={{ width: 18, height: 18, color: "#6b7280" }} />
|
||||
<ArrowLeft style={{ width: 18, height: 18, color: "#6b7280" }} />
|
||||
</button>
|
||||
<span style={{ fontSize: 14, color: "#6b7280" }}>Policies</span>
|
||||
<span style={{ fontSize: 14, color: "#d1d5db" }}>/</span>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="Policy name..."
|
||||
value={policyName}
|
||||
onChange={(e) => setPolicyName(e.target.value)}
|
||||
|
|
@ -1564,7 +1586,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
<Button variant="secondary" onClick={() => setShowTestPanel(!showTestPanel)}>
|
||||
{showTestPanel ? "Hide Test" : "Test Pipeline"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={isSubmitting}>
|
||||
<Button onClick={handleSave} disabled={isSubmitting}>
|
||||
{isEditing ? "Update Policy" : "Save Policy"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1579,7 +1601,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="Add a description (optional)..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Badge, Button } from "@tremor/react";
|
||||
import { ArrowLeftIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { Descriptions, Tag, Spin, Divider, Typography, Alert } from "antd";
|
||||
import { ArrowLeft, Info, Pencil } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Policy } from "@/components/policies/types";
|
||||
import { PipelineInfoDisplay } from "./pipeline_flow_builder";
|
||||
import { getResolvedGuardrails } from "@/components/networking";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface PolicyInfoViewProps {
|
||||
policyId: string;
|
||||
onClose: () => void;
|
||||
|
|
@ -17,6 +19,29 @@ interface PolicyInfoViewProps {
|
|||
getPolicy: (accessToken: string, policyId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DetailRow = ({ label, children }: DetailRowProps) => (
|
||||
<div className="grid grid-cols-1 border-b border-border last:border-b-0 sm:grid-cols-[200px_minmax(0,1fr)]">
|
||||
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium">{label}</dt>
|
||||
<dd className="px-4 py-3 text-sm">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeading = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold">{children}</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const Muted = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="text-muted-foreground">{children}</span>
|
||||
);
|
||||
|
||||
const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
|
||||
policyId,
|
||||
onClose,
|
||||
|
|
@ -61,8 +86,9 @@ const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-12">
|
||||
<Spin size="large" />
|
||||
<div className="flex flex-col items-center gap-3 p-12">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full max-w-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,144 +96,130 @@ const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
|
|||
if (!policy) {
|
||||
return (
|
||||
<Card>
|
||||
<Text type="danger">Policy not found</Text>
|
||||
<br />
|
||||
<Button onClick={onClose} className="mt-4">
|
||||
Go Back
|
||||
</Button>
|
||||
<CardContent>
|
||||
<p className="text-destructive">Policy not found</p>
|
||||
<Button variant="secondary" onClick={onClose} className="mt-4">
|
||||
Go Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="secondary" icon={ArrowLeftIcon} onClick={onClose}>
|
||||
Back to Policies
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button icon={PencilIcon} onClick={() => onEdit(policy)}>
|
||||
Edit Policy
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
<ArrowLeft />
|
||||
Back to Policies
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Title level={4}>{policy.policy_name}</Title>
|
||||
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="Policy ID">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded-sm">{policy.policy_id}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Description">
|
||||
{policy.description || <Text type="secondary">No description</Text>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Inherits From">
|
||||
{policy.inherit ? (
|
||||
<Badge color="blue" size="sm">
|
||||
{policy.inherit}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text type="secondary">None</Text>
|
||||
{isAdmin && (
|
||||
<Button onClick={() => onEdit(policy)}>
|
||||
<Pencil />
|
||||
Edit Policy
|
||||
</Button>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Created At">
|
||||
{policy.created_at ? new Date(policy.created_at).toLocaleString() : "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Updated At">
|
||||
{policy.updated_at ? new Date(policy.updated_at).toLocaleString() : "-"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
{policy.pipeline && (
|
||||
<>
|
||||
<Divider orientation="left">
|
||||
<Text strong>Pipeline Flow</Text>
|
||||
</Divider>
|
||||
<Alert
|
||||
message={`Pipeline (${policy.pipeline.mode} mode, ${policy.pipeline.steps.length} step${policy.pipeline.steps.length !== 1 ? "s" : ""})`}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<PipelineInfoDisplay pipeline={policy.pipeline} />
|
||||
</>
|
||||
)}
|
||||
<h4 className="text-lg font-semibold">{policy.policy_name}</h4>
|
||||
|
||||
<Divider orientation="left">
|
||||
<Text strong>Guardrails Configuration</Text>
|
||||
</Divider>
|
||||
<dl className="rounded-md border border-border">
|
||||
<DetailRow label="Policy ID">
|
||||
<code className="rounded-sm bg-muted px-2 py-1 text-xs">{policy.policy_id}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label="Description">{policy.description || <Muted>No description</Muted>}</DetailRow>
|
||||
<DetailRow label="Inherits From">
|
||||
{policy.inherit ? <Badge variant="secondary">{policy.inherit}</Badge> : <Muted>None</Muted>}
|
||||
</DetailRow>
|
||||
<DetailRow label="Created At">
|
||||
{policy.created_at ? new Date(policy.created_at).toLocaleString() : "-"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Updated At">
|
||||
{policy.updated_at ? new Date(policy.updated_at).toLocaleString() : "-"}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
{resolvedGuardrails.length > 0 && (
|
||||
<Alert
|
||||
message="Resolved Guardrails"
|
||||
description={
|
||||
<div>
|
||||
<Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
|
||||
Final guardrails that will be applied (including inheritance):
|
||||
</Text>
|
||||
{policy.pipeline && (
|
||||
<>
|
||||
<SectionHeading>Pipeline Flow</SectionHeading>
|
||||
<Alert className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>
|
||||
Pipeline ({policy.pipeline.mode} mode, {policy.pipeline.steps.length} step
|
||||
{policy.pipeline.steps.length !== 1 ? "s" : ""})
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
<PipelineInfoDisplay pipeline={policy.pipeline} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeading>Guardrails Configuration</SectionHeading>
|
||||
|
||||
{resolvedGuardrails.length > 0 && (
|
||||
<Alert className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Resolved Guardrails</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="mb-2 block">Final guardrails that will be applied (including inheritance):</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{resolvedGuardrails.map((g) => (
|
||||
<Tag key={g} color="blue">
|
||||
<Badge key={g} variant="secondary">
|
||||
{g}
|
||||
</Tag>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<dl className="rounded-md border border-border">
|
||||
<DetailRow label="Guardrails to Add">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.guardrails_add && policy.guardrails_add.length > 0 ? (
|
||||
policy.guardrails_add.map((g) => (
|
||||
<Badge key={g} variant="secondary">
|
||||
{g}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Muted>None</Muted>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Guardrails to Remove">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.guardrails_remove && policy.guardrails_remove.length > 0 ? (
|
||||
policy.guardrails_remove.map((g) => (
|
||||
<Badge key={g} variant="destructive">
|
||||
{g}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Muted>None</Muted>
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="Guardrails to Add">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.guardrails_add && policy.guardrails_add.length > 0 ? (
|
||||
policy.guardrails_add.map((g) => (
|
||||
<Tag key={g} color="green">
|
||||
{g}
|
||||
</Tag>
|
||||
))
|
||||
<SectionHeading>Conditions</SectionHeading>
|
||||
|
||||
<dl className="rounded-md border border-border">
|
||||
<DetailRow label="Model Condition">
|
||||
{policy.condition?.model ? (
|
||||
<Badge variant="secondary">
|
||||
{typeof policy.condition.model === "string"
|
||||
? policy.condition.model
|
||||
: JSON.stringify(policy.condition.model)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text type="secondary">None</Text>
|
||||
<Muted>No model condition (applies to all models)</Muted>
|
||||
)}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Guardrails to Remove">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.guardrails_remove && policy.guardrails_remove.length > 0 ? (
|
||||
policy.guardrails_remove.map((g) => (
|
||||
<Tag key={g} color="red">
|
||||
{g}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">None</Text>
|
||||
)}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left">
|
||||
<Text strong>Conditions</Text>
|
||||
</Divider>
|
||||
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="Model Condition">
|
||||
{policy.condition?.model ? (
|
||||
<Tag color="purple">
|
||||
{typeof policy.condition.model === "string"
|
||||
? policy.condition.model
|
||||
: JSON.stringify(policy.condition.model)}
|
||||
</Tag>
|
||||
) : (
|
||||
<Text type="secondary">No model condition (applies to all models)</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Card, Button, Spin, Checkbox } from "antd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
ShieldExclamationIcon,
|
||||
BeakerIcon,
|
||||
CurrencyDollarIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ShieldCheck, ShieldAlert, FlaskConical, CircleDollarSign, CheckCircle2 } from "lucide-react";
|
||||
import { getPolicyTemplates } from "@/components/networking";
|
||||
|
||||
interface PolicyTemplateCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
guardrails: string[];
|
||||
|
|
@ -35,73 +33,53 @@ const PolicyTemplateCard: React.FC<PolicyTemplateCardProps> = ({
|
|||
complexity,
|
||||
onUseTemplate,
|
||||
}) => {
|
||||
const getComplexityStyle = () => {
|
||||
switch (complexity) {
|
||||
case "Low":
|
||||
return "bg-gray-50 text-gray-600 border-gray-200";
|
||||
case "Medium":
|
||||
return "bg-blue-50 text-blue-600 border-blue-100";
|
||||
case "High":
|
||||
return "bg-purple-50 text-purple-600 border-purple-100";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full hover:shadow-md transition-shadow"
|
||||
bodyStyle={{ display: "flex", flexDirection: "column", height: "100%" }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`p-2 rounded-lg ${iconBg}`}>
|
||||
<Icon className={`h-6 w-6 ${iconColor}`} />
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardContent className="flex h-full flex-col">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className={`rounded-lg p-2 ${iconBg}`}>
|
||||
<Icon className={`size-6 ${iconColor}`} />
|
||||
</div>
|
||||
<Badge variant="outline">{complexity} Complexity</Badge>
|
||||
</div>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${getComplexityStyle()}`}>
|
||||
{complexity} Complexity
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4 grow">{description}</p>
|
||||
<h3 className="mb-2 text-base font-semibold">{title}</h3>
|
||||
<p className="mb-4 grow text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{tags.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inherits && (
|
||||
<div className="mb-4 text-xs">
|
||||
<span className="text-muted-foreground">Inherits from: </span>
|
||||
<span className="rounded-sm bg-muted px-2 py-0.5 font-medium">{inherits}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="mb-2 block text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||
Included Guardrails
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{guardrails.map((g) => (
|
||||
<Badge key={g} variant="outline">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inherits && (
|
||||
<div className="mb-4 text-xs">
|
||||
<span className="text-gray-500">Inherits from: </span>
|
||||
<span className="font-medium text-gray-700 bg-gray-100 px-2 py-0.5 rounded-sm">{inherits}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider block mb-2">
|
||||
Included Guardrails
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{guardrails.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="inline-flex items-center px-2 py-1 rounded-sm text-xs font-medium bg-gray-50 text-gray-700 border border-gray-200"
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="primary" block className="mt-auto" onClick={onUseTemplate}>
|
||||
Use Template
|
||||
</Button>
|
||||
<Button className="mt-auto w-full" onClick={onUseTemplate}>
|
||||
Use Template
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -114,12 +92,12 @@ interface PolicyTemplatesProps {
|
|||
}
|
||||
|
||||
// Map icon names from JSON to actual icon components
|
||||
const iconMap: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
|
||||
ShieldCheckIcon: ShieldCheckIcon,
|
||||
ShieldExclamationIcon: ShieldExclamationIcon,
|
||||
BeakerIcon: BeakerIcon,
|
||||
CurrencyDollarIcon: CurrencyDollarIcon,
|
||||
CheckCircleIcon: CheckCircleIcon,
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
ShieldCheckIcon: ShieldCheck,
|
||||
ShieldExclamationIcon: ShieldAlert,
|
||||
BeakerIcon: FlaskConical,
|
||||
CurrencyDollarIcon: CircleDollarSign,
|
||||
CheckCircleIcon: CheckCircle2,
|
||||
};
|
||||
|
||||
const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
||||
|
|
@ -192,8 +170,10 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Spin size="large" tip="Loading policy templates..." />
|
||||
<div className="grid grid-cols-1 gap-6 py-20 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -202,12 +182,12 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-gray-900">Policy Templates</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<h2 className="text-lg font-medium">Policy Templates</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Start with a pre-configured policy template to quickly set up guardrails for your organization.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="default" onClick={onOpenAiSuggestion} className="flex items-center gap-1.5">
|
||||
<Button variant="outline" onClick={onOpenAiSuggestion}>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1l1.5 3.5L13 6l-3.5 1.5L8 11 6.5 7.5 3 6l3.5-1.5L8 1zm4 7l.75 1.75L14.5 10.5l-1.75.75L12 13l-.75-1.75L9.5 10.5l1.75-.75L12 8zM4 9l.75 1.75L6.5 11.5l-1.75.75L4 14l-.75-1.75L1.5 11.5l1.75-.75L4 9z" />
|
||||
</svg>
|
||||
|
|
@ -221,9 +201,9 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
<div className="w-52 shrink-0">
|
||||
<div className="sticky top-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-gray-900">Categories</span>
|
||||
<span className="text-sm font-semibold">Categories</span>
|
||||
{selectedTags.size > 0 && (
|
||||
<button onClick={handleClearAll} className="text-xs text-blue-600 hover:text-blue-800">
|
||||
<button onClick={handleClearAll} className="text-xs text-primary hover:underline">
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -233,14 +213,14 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
<label
|
||||
key={tag}
|
||||
className={`flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
|
||||
selectedTags.has(tag) ? "bg-blue-50" : "hover:bg-gray-50"
|
||||
selectedTags.has(tag) ? "bg-accent" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={selectedTags.has(tag)} onChange={() => handleTagToggle(tag)} />
|
||||
<span className="text-sm text-gray-700">{tag}</span>
|
||||
<Checkbox checked={selectedTags.has(tag)} onCheckedChange={() => handleTagToggle(tag)} />
|
||||
<span className="text-sm">{tag}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 font-medium">{count}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{count}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -251,7 +231,7 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
{/* Right content - template cards */}
|
||||
<div className="flex-1">
|
||||
{selectedTags.size > 0 && (
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
Showing {filteredTemplates.length} of {templates.length} templates
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -261,7 +241,7 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
key={template.id || index}
|
||||
title={template.title}
|
||||
description={template.description}
|
||||
icon={iconMap[template.icon] || ShieldCheckIcon}
|
||||
icon={iconMap[template.icon] || ShieldCheck}
|
||||
iconColor={template.iconColor}
|
||||
iconBg={template.iconBg}
|
||||
guardrails={template.guardrails}
|
||||
|
|
@ -274,9 +254,9 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
|
|||
</div>
|
||||
|
||||
{filteredTemplates.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<p>No templates match the selected filters.</p>
|
||||
<button onClick={handleClearAll} className="text-blue-600 hover:text-blue-800 mt-2 text-sm">
|
||||
<button onClick={handleClearAll} className="mt-2 text-sm text-primary hover:underline">
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Spin, Radio, Select } from "antd";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { X } from "lucide-react";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { modelHubCall, enrichPolicyTemplateStream } from "@/components/networking";
|
||||
|
||||
interface TemplateParameter {
|
||||
|
|
@ -43,6 +56,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
|||
const [isRefining, setIsRefining] = useState(false);
|
||||
const [hasGenerated, setHasGenerated] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [tagDraft, setTagDraft] = useState("");
|
||||
|
||||
const parameters: TemplateParameter[] = template?.parameters || [];
|
||||
const hasEnrichment = !!template?.llm_enrichment;
|
||||
|
|
@ -66,6 +80,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
|||
setIsRefining(false);
|
||||
setHasGenerated(false);
|
||||
setStatusMessage("");
|
||||
setTagDraft("");
|
||||
}
|
||||
}, [visible, template]);
|
||||
|
||||
|
|
@ -181,205 +196,211 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
|||
? allNonEnrichmentFilled && brandNameFilled && competitorTags.length > 0
|
||||
: allNonEnrichmentFilled && brandNameFilled;
|
||||
|
||||
const addCompetitorTags = (raw: string) => {
|
||||
const additions = raw
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0 && !competitorTags.some((t) => t.toLowerCase() === name.toLowerCase()));
|
||||
if (additions.length > 0) setCompetitorTags([...competitorTags, ...additions]);
|
||||
setTagDraft("");
|
||||
};
|
||||
|
||||
const handleTagDraftKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addCompetitorTags(tagDraft);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && tagDraft === "" && competitorTags.length > 0) {
|
||||
setCompetitorTags(competitorTags.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(parameterValues, { competitors: competitorTags });
|
||||
};
|
||||
|
||||
const renderParameterField = (param: TemplateParameter) => (
|
||||
<div key={param.name}>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
{param.label}
|
||||
{param.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={param.placeholder || ""}
|
||||
value={parameterValues[param.name] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">{template?.title}</h3>
|
||||
<p className="text-sm text-gray-500 font-normal">Configure competitor blocking for your brand</p>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={700}
|
||||
footer={[
|
||||
<Button key="cancel" variant="secondary" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="confirm" onClick={handleConfirm} loading={isLoading} disabled={!canContinue || isLoading}>
|
||||
{isLoading ? "Creating guardrails..." : "Continue"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="py-4 space-y-4">
|
||||
{nonEnrichmentParams.map((param) => (
|
||||
<div key={param.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{param.label}
|
||||
{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder={param.placeholder || ""}
|
||||
value={parameterValues[param.name] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="sm:max-w-175">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg">{template?.title}</DialogTitle>
|
||||
<DialogDescription>Configure competitor blocking for your brand</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasEnrichment && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Competitor Discovery</label>
|
||||
<Radio.Group
|
||||
value={competitorMode}
|
||||
onChange={(e) => setCompetitorMode(e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Radio.Button value="ai" className="flex-1 text-center">
|
||||
✨ Use AI
|
||||
</Radio.Button>
|
||||
<Radio.Button value="manual" className="flex-1 text-center">
|
||||
Enter Manually
|
||||
</Radio.Button>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="space-y-4 py-4">
|
||||
{nonEnrichmentParams.map(renderParameterField)}
|
||||
|
||||
{/* Brand Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Brand Name
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder="e.g. Acme Airlines"
|
||||
value={parameterValues[enrichmentParam || "brand_name"] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[enrichmentParam || "brand_name"]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{competitorMode === "ai" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select Model
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Select
|
||||
placeholder="Select a model to generate names"
|
||||
value={selectedModel}
|
||||
onChange={(value) => setSelectedModel(value)}
|
||||
loading={isLoadingModels}
|
||||
showSearch
|
||||
className="w-full"
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNames}
|
||||
loading={isGenerating}
|
||||
disabled={!selectedModel || !brandNameFilled || isGenerating}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? "✨ Generating names..." : "✨ Generate Competitor Names"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Competitor Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Competitor Names
|
||||
{competitorTags.length > 0 && (
|
||||
<span className="text-gray-400 font-normal ml-2">({competitorTags.length})</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Type a name and press Enter to add"
|
||||
value={competitorTags}
|
||||
onChange={(values) => setCompetitorTags(values)}
|
||||
tokenSeparators={[","]}
|
||||
open={false}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Type a name and press Enter to add. Click ✕ to remove.</p>
|
||||
{statusMessage && (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50 rounded-sm border border-blue-100">
|
||||
<Spin size="small" />
|
||||
<span className="text-xs text-blue-700">{statusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(variationsMap).length > 0 && !statusMessage && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
✓ {Object.values(variationsMap).flat().length} alternate spellings & variations auto-generated for
|
||||
guardrail matching
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refinement input — shown after initial generation in AI mode */}
|
||||
{competitorMode === "ai" && hasGenerated && competitorTags.length > 0 && (
|
||||
{hasEnrichment && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Refine List</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="e.g. add 10 more from Asia, increase to 50 total..."
|
||||
value={refinementInput}
|
||||
onChange={(e) => setRefinementInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && refinementInput.trim() && !isRefining) {
|
||||
handleRefine();
|
||||
}
|
||||
}}
|
||||
disabled={isRefining}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleRefine}
|
||||
loading={isRefining}
|
||||
disabled={!refinementInput.trim() || isRefining}
|
||||
size="xs"
|
||||
>
|
||||
{isRefining ? "..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Give instructions to add, remove, or change competitors. Press Enter to send.
|
||||
</p>
|
||||
<label className="mb-2 block text-sm font-medium">Competitor Discovery</label>
|
||||
<RadioGroup
|
||||
value={competitorMode}
|
||||
onValueChange={(value) => setCompetitorMode(value as "ai" | "manual")}
|
||||
className="grid-cols-2"
|
||||
>
|
||||
<label className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-input px-3 py-2 text-sm">
|
||||
<RadioGroupItem value="ai" />✨ Use AI
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-input px-3 py-2 text-sm">
|
||||
<RadioGroupItem value="manual" />
|
||||
Enter Manually
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasEnrichment &&
|
||||
parameters.map((param) => (
|
||||
<div key={param.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{param.label}
|
||||
{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder={param.placeholder || ""}
|
||||
value={parameterValues[param.name] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Brand Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Your Brand Name
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Acme Airlines"
|
||||
value={parameterValues[enrichmentParam || "brand_name"] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[enrichmentParam || "brand_name"]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{competitorMode === "ai" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Select Model
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
value={selectedModel}
|
||||
onValueChange={(value) => setSelectedModel(value || undefined)}
|
||||
placeholder={isLoadingModels ? "Loading models..." : "Select a model to generate names"}
|
||||
emptyText="No models found"
|
||||
disabled={isLoadingModels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNames}
|
||||
disabled={!selectedModel || !brandNameFilled || isGenerating}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? "✨ Generating names..." : "✨ Generate Competitor Names"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Competitor Tags */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Competitor Names
|
||||
{competitorTags.length > 0 && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">({competitorTags.length})</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-input p-2">
|
||||
{competitorTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={() => setCompetitorTags(competitorTags.filter((t) => t !== tag))}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<input
|
||||
className="min-w-40 flex-1 bg-transparent text-sm outline-none"
|
||||
placeholder="Type a name and press Enter to add"
|
||||
value={tagDraft}
|
||||
onChange={(e) => setTagDraft(e.target.value)}
|
||||
onKeyDown={handleTagDraftKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Type a name and press Enter to add. Click ✕ to remove.
|
||||
</p>
|
||||
{statusMessage && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-sm border border-border bg-muted p-2">
|
||||
<UiLoadingSpinner className="size-3" />
|
||||
<span className="text-xs text-muted-foreground">{statusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(variationsMap).length > 0 && !statusMessage && (
|
||||
<p className="mt-1 text-xs text-green-600">
|
||||
✓ {Object.values(variationsMap).flat().length} alternate spellings & variations auto-generated
|
||||
for guardrail matching
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refinement input — shown after initial generation in AI mode */}
|
||||
{competitorMode === "ai" && hasGenerated && competitorTags.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Refine List</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g. add 10 more from Asia, increase to 50 total..."
|
||||
value={refinementInput}
|
||||
onChange={(e) => setRefinementInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && refinementInput.trim() && !isRefining) {
|
||||
handleRefine();
|
||||
}
|
||||
}}
|
||||
disabled={isRefining}
|
||||
/>
|
||||
<Button onClick={handleRefine} disabled={!refinementInput.trim() || isRefining} size="sm">
|
||||
{isRefining ? "..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Give instructions to add, remove, or change competitors. Press Enter to send.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasEnrichment && parameters.map(renderParameterField)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canContinue || isLoading}>
|
||||
{isLoading ? "Creating guardrails..." : "Continue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCloudZeroSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Card, Typography } from "antd";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder";
|
||||
import { useState } from "react";
|
||||
import CloudZeroCreationModal from "./CloudZeroCreateModal";
|
||||
|
|
@ -28,7 +28,9 @@ export default function CloudZeroCostTracking() {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Text>Loading CloudZero settings...</Typography.Text>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Loading CloudZero settings...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,9 +38,11 @@ export default function CloudZeroCostTracking() {
|
|||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Text className="text-red-600">
|
||||
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
|
||||
</Typography.Text>
|
||||
<CardContent>
|
||||
<p className="text-sm text-destructive">
|
||||
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Empty, Typography, Button } from "antd";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
interface CloudZeroEmptyPlaceholderProps {
|
||||
startCreation: () => void;
|
||||
|
|
@ -8,22 +7,17 @@ interface CloudZeroEmptyPlaceholderProps {
|
|||
|
||||
export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) {
|
||||
return (
|
||||
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center max-w-2xl mx-auto mt-8">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<Title level={4}>No CloudZero Integration Found</Title>
|
||||
<Paragraph type="secondary" className="max-w-md mx-auto">
|
||||
Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM.
|
||||
</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button type="primary" size="large" onClick={startCreation} className="flex items-center gap-2 mx-auto mt-4">
|
||||
<div className="mx-auto mt-8 max-w-2xl rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Inbox className="size-10 text-muted-foreground" aria-hidden />
|
||||
<h4 className="text-base font-semibold">No CloudZero Integration Found</h4>
|
||||
<p className="mx-auto max-w-md text-sm text-muted-foreground">
|
||||
Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM.
|
||||
</p>
|
||||
<Button size="lg" onClick={startCreation} className="mt-4">
|
||||
Add CloudZero Integration
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,22 @@ import { useCloudZeroExport } from "@/app/(dashboard)/hooks/cloudzero/useCloudZe
|
|||
import { useCloudZeroDeleteSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { Alert, Button, Card, Descriptions, Divider, Popconfirm, Tag } from "antd";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { CheckCircle, Edit, Play, Trash2, Upload } from "lucide-react";
|
||||
import { CheckCircle, Pencil, Play, Trash2, Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import CloudZeroUpdateModal from "./CloudZeroUpdateModal";
|
||||
import { CloudZeroSettings } from "./types";
|
||||
|
|
@ -15,10 +28,25 @@ interface CloudZeroIntegrationSettingsProps {
|
|||
onSettingsUpdated: () => void;
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DetailRow = ({ label, children }: DetailRowProps) => (
|
||||
<div className="grid grid-cols-1 border-b border-border last:border-b-0 sm:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium">{label}</dt>
|
||||
<dd className="px-4 py-3 text-sm">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NotConfigured = () => <span className="text-muted-foreground italic">Not configured</span>;
|
||||
|
||||
export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isExportConfirmOpen, setIsExportConfirmOpen] = useState(false);
|
||||
|
||||
const dryRunMutation = useCloudZeroDryRun(accessToken || "");
|
||||
const exportMutation = useCloudZeroExport(accessToken || "");
|
||||
|
|
@ -50,6 +78,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
{
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Data successfully exported to CloudZero");
|
||||
setIsExportConfirmOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
MessageManager.error(error?.message || "Failed to export data");
|
||||
|
|
@ -96,112 +125,90 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 w-full max-w-4xl mx-auto">
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">CloudZero Configuration</span>
|
||||
<Tag color="success" className="ml-2 capitalize">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
CloudZero Configuration
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{settings.status || "Active"}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<Edit size={16} />} onClick={handleEdit} className="flex items-center gap-2">
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardAction className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={handleDeleteClick}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDeleteClick}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<dl className="rounded-md border border-border">
|
||||
<DetailRow label="API Key (Redacted)">
|
||||
<span className="font-mono">{settings.api_key_masked || <NotConfigured />}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Connection ID">
|
||||
<span className="font-mono">{settings.connection_id || <NotConfigured />}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Timezone">
|
||||
{settings.timezone || <span className="text-muted-foreground italic">Default (UTC)</span>}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Actions</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
}
|
||||
className="shadow-xs"
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xxl: 1,
|
||||
xl: 1,
|
||||
lg: 1,
|
||||
md: 1,
|
||||
sm: 1,
|
||||
xs: 1,
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label="API Key (Redacted)">
|
||||
<span className="font-mono text-gray-600">
|
||||
{settings.api_key_masked || <span className="text-gray-400 italic">Not configured</span>}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Connection ID">
|
||||
<span className="font-mono text-gray-600">
|
||||
{settings.connection_id || <span className="text-gray-400 italic">Not configured</span>}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Timezone">
|
||||
{settings.timezone || <span className="text-gray-400 italic">Default (UTC)</span>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left" className="text-gray-500">
|
||||
Actions
|
||||
</Divider>
|
||||
<div className="mt-4 mb-6 flex flex-wrap gap-4">
|
||||
<Button variant="outline" onClick={handleDryRun} disabled={dryRunMutation.isPending}>
|
||||
<Play />
|
||||
Run Dry Run Simulation
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<Button
|
||||
onClick={handleDryRun}
|
||||
loading={dryRunMutation.isPending}
|
||||
icon={<Play size={16} />}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Run Dry Run Simulation
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title="Export Data to CloudZero"
|
||||
description="This will push the current accumulated cost data to CloudZero. Continue?"
|
||||
onConfirm={handleExport}
|
||||
okText="Export"
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={exportMutation.isPending}
|
||||
icon={<Upload size={16} />}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Button onClick={() => setIsExportConfirmOpen(true)} disabled={exportMutation.isPending}>
|
||||
<Upload />
|
||||
Export Data Now
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
|
||||
{dryRunResult && (
|
||||
<div className="mt-6 animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<Alert
|
||||
message="Dry Run Results"
|
||||
description={
|
||||
<div className="mt-2">
|
||||
<p className="mb-2 text-gray-600">Simulation output for connection: {settings.connection_id}</p>
|
||||
<pre className="bg-gray-50 p-4 rounded-md border border-gray-200 overflow-x-auto text-xs font-mono text-gray-800">
|
||||
{dryRunResult}
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<CheckCircle className="text-blue-500" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dryRunResult && (
|
||||
<Alert>
|
||||
<CheckCircle />
|
||||
<AlertTitle>Dry Run Results</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>Simulation output for connection: {settings.connection_id}</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted p-4 font-mono text-xs text-foreground">
|
||||
{dryRunResult}
|
||||
</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isExportConfirmOpen} onOpenChange={setIsExportConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Export Data to CloudZero</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will push the current accumulated cost data to CloudZero. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={exportMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<Button onClick={handleExport} disabled={exportMutation.isPending}>
|
||||
Export
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<CloudZeroUpdateModal
|
||||
open={isEditModalOpen}
|
||||
onOk={handleEditModalOk}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Text, Button } from "@tremor/react";
|
||||
import { Typography, Divider, Spin, Checkbox } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } from "../networking";
|
||||
import { EmailEvent } from "../../types";
|
||||
import { EmailEventSetting } from "./types";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface EmailEventSettingsProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
|
@ -88,39 +89,46 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>Email Notifications</Title>
|
||||
<Text>Select which events should trigger email notifications.</Text>
|
||||
<Divider />
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email Notifications</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Select which events should trigger email notifications.</p>
|
||||
</CardHeader>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{eventSettings.map((setting) => (
|
||||
<div key={setting.event} className="flex items-center">
|
||||
<Checkbox
|
||||
checked={setting.enabled}
|
||||
onChange={(e) => handleCheckboxChange(setting.event, e.target.checked)}
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<Text>{setting.event}</Text>
|
||||
<div className="text-sm text-gray-500 block">{getEventDescription(setting.event)}</div>
|
||||
<CardContent>
|
||||
<Separator className="mb-6" />
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{eventSettings.map((setting) => (
|
||||
<div key={setting.event} className="flex items-start">
|
||||
<Checkbox
|
||||
checked={setting.enabled}
|
||||
onCheckedChange={(checked) => handleCheckboxChange(setting.event, checked === true)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm">{setting.event}</p>
|
||||
<div className="block text-sm text-muted-foreground">{getEventDescription(setting.event)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex space-x-4">
|
||||
<Button onClick={handleSaveSettings} disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button onClick={handleResetSettings} variant="secondary" disabled={loading}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-4">
|
||||
<Button onClick={handleSaveSettings} disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleResetSettings} disabled={loading}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,34 @@
|
|||
import React from "react";
|
||||
import { Card, Text, Grid, Button, TextInput, TableCell } from "@tremor/react";
|
||||
import { Typography } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import NotificationManager from "./molecules/notifications_manager";
|
||||
import { serviceHealthCheck, setCallbacksCall } from "./networking";
|
||||
import { EmailEventSettings } from "./email_events";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface EmailSettingsProps {
|
||||
accessToken: string | null;
|
||||
premiumUser: boolean;
|
||||
alerts: any[];
|
||||
}
|
||||
|
||||
const REQUIRED_MARKER = <span className="text-destructive"> Required * </span>;
|
||||
|
||||
const FIELD_HELP: Record<string, React.ReactNode> = {
|
||||
SMTP_HOST: <>Enter the SMTP host address, e.g. `smtp.resend.com`{REQUIRED_MARKER}</>,
|
||||
SMTP_PORT: <>Enter the SMTP port number, e.g. `587`{REQUIRED_MARKER}</>,
|
||||
SMTP_USERNAME: <>Enter the SMTP username, e.g. `username`{REQUIRED_MARKER}</>,
|
||||
SMTP_PASSWORD: REQUIRED_MARKER,
|
||||
SMTP_SENDER_EMAIL: <>Enter the sender email address, e.g. `sender@berri.ai`{REQUIRED_MARKER}</>,
|
||||
TEST_EMAIL_ADDRESS: <>Email Address to send `Test Email Alert` to. example: `info@berri.ai`{REQUIRED_MARKER}</>,
|
||||
EMAIL_LOGO_URL: <>(Optional) Customize the Logo that appears in the email, pass a url to your logo</>,
|
||||
EMAIL_SUPPORT_CONTACT: (
|
||||
<>(Optional) Customize the support email address that appears in the email. Default is support@berri.ai</>
|
||||
),
|
||||
};
|
||||
|
||||
const PREMIUM_ONLY_FIELDS = ["EMAIL_LOGO_URL", "EMAIL_SUPPORT_CONTACT"];
|
||||
|
||||
const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser, alerts }) => {
|
||||
const handleSaveEmailSettings = async () => {
|
||||
if (!accessToken) {
|
||||
|
|
@ -62,124 +78,73 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
<EmailEventSettings accessToken={accessToken} />
|
||||
</div>
|
||||
<Card>
|
||||
<Title level={4}>Email Server Settings</Title>
|
||||
<Text>
|
||||
<a href="https://docs.litellm.ai/docs/proxy/email" target="_blank" style={{ color: "blue" }}>
|
||||
{" "}
|
||||
LiteLLM Docs: email alerts
|
||||
</a>{" "}
|
||||
<br />
|
||||
</Text>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email Server Settings</CardTitle>
|
||||
<p className="text-sm">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/email"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
LiteLLM Docs: email alerts
|
||||
</a>
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<div className="flex w-full">
|
||||
<CardContent>
|
||||
{alerts
|
||||
.filter((alert) => alert.name === "email")
|
||||
.map((alert, index) => (
|
||||
<TableCell key={index}>
|
||||
<ul>
|
||||
<Grid numItems={2}>
|
||||
{Object.entries(alert.variables ?? {}).map(([key, value]) => (
|
||||
<li key={key} className="mx-2 my-2">
|
||||
{premiumUser != true && (key === "EMAIL_LOGO_URL" || key === "EMAIL_SUPPORT_CONTACT") ? (
|
||||
<div>
|
||||
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
|
||||
<Text className="mt-2"> ✨ {key}</Text>
|
||||
</a>
|
||||
<TextInput
|
||||
name={key}
|
||||
defaultValue={value as string}
|
||||
type="password"
|
||||
disabled={true}
|
||||
style={{ width: "400px" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Text className="mt-2">{key}</Text>
|
||||
<TextInput
|
||||
name={key}
|
||||
defaultValue={value as string}
|
||||
type="password"
|
||||
style={{ width: "400px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Added descriptions for input fields */}
|
||||
<p style={{ fontSize: "small", fontStyle: "italic" }}>
|
||||
{key === "SMTP_HOST" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
Enter the SMTP host address, e.g. `smtp.resend.com`
|
||||
<span style={{ color: "red" }}> Required * </span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{key === "SMTP_PORT" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
Enter the SMTP port number, e.g. `587`
|
||||
<span style={{ color: "red" }}> Required * </span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{key === "SMTP_USERNAME" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
Enter the SMTP username, e.g. `username`
|
||||
<span style={{ color: "red" }}> Required * </span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{key === "SMTP_PASSWORD" && <span style={{ color: "red" }}> Required * </span>}
|
||||
|
||||
{key === "SMTP_SENDER_EMAIL" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
Enter the sender email address, e.g. `sender@berri.ai`
|
||||
<span style={{ color: "red" }}> Required * </span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{key === "TEST_EMAIL_ADDRESS" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
Email Address to send `Test Email Alert` to. example: `info@berri.ai`
|
||||
<span style={{ color: "red" }}> Required * </span>
|
||||
</div>
|
||||
)}
|
||||
{key === "EMAIL_LOGO_URL" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
(Optional) Customize the Logo that appears in the email, pass a url to your logo
|
||||
</div>
|
||||
)}
|
||||
{key === "EMAIL_SUPPORT_CONTACT" && (
|
||||
<div style={{ color: "gray" }}>
|
||||
(Optional) Customize the support email address that appears in the email. Default is
|
||||
support@berri.ai
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</Grid>
|
||||
</ul>
|
||||
</TableCell>
|
||||
<div key={index} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{Object.entries(alert.variables ?? {}).map(([key, value]) => {
|
||||
const isLocked = !premiumUser && PREMIUM_ONLY_FIELDS.includes(key);
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
{isLocked ? (
|
||||
<a
|
||||
href="https://forms.gle/W3U4PZpJGFHWtHyA9"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary underline underline-offset-4"
|
||||
>
|
||||
✨ {key}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm">{key}</p>
|
||||
)}
|
||||
<Input
|
||||
name={key}
|
||||
defaultValue={value as string}
|
||||
type="password"
|
||||
disabled={isLocked}
|
||||
className="max-w-100"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground italic">{FIELD_HELP[key]}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button className="mt-2" onClick={() => handleSaveEmailSettings()}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "email");
|
||||
NotificationManager.success("Email test triggered. Check your configured email inbox/logs.");
|
||||
} catch (error) {
|
||||
NotificationManager.fromBackend(error);
|
||||
}
|
||||
}}
|
||||
className="mx-2"
|
||||
>
|
||||
Test Email Alerts
|
||||
</Button>
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button onClick={() => handleSaveEmailSettings()}>Save Changes</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "email");
|
||||
NotificationManager.success("Email test triggered. Check your configured email inbox/logs.");
|
||||
} catch (error) {
|
||||
NotificationManager.fromBackend(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Test Email Alerts
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
29
ui/litellm-dashboard/src/components/ui/radio-group.tsx
Normal file
29
ui/litellm-dashboard/src/components/ui/radio-group.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||
return <RadioGroupPrimitive data-slot="radio-group" className={cn("grid w-full gap-3", className)} {...props} />;
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
|
||||
return (
|
||||
<RadioPrimitive.Root
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioPrimitive.Indicator data-slot="radio-group-indicator" className="flex size-4 items-center justify-center">
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
Loading…
Add table
Reference in a new issue