mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #24472 from BerriAI/litellm_extract_pipeline_test_panel
[Refactor] UI - Policies: Extract PipelineTestPanel from pipeline_flow_builder
This commit is contained in:
commit
0ce1ea37c5
5 changed files with 1253 additions and 612 deletions
|
|
@ -2,199 +2,30 @@ 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 { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import { DotsVerticalIcon } from "@heroicons/react/solid";
|
||||
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
|
||||
import { GuardrailPipeline, PipelineStep, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
|
||||
import { Guardrail } from "../guardrails/types";
|
||||
import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
|
||||
import { listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import {
|
||||
getComplianceDatasetPrompts,
|
||||
getFrameworks,
|
||||
} from "../../data/compliancePrompts";
|
||||
import type { CompliancePrompt } from "../../data/compliancePrompts";
|
||||
|
||||
const TEST_SOURCE_QUICK = "quick_chat";
|
||||
const TEST_SOURCE_ALL = "__all__";
|
||||
|
||||
function getPromptsForTestSource(source: string): CompliancePrompt[] {
|
||||
if (source === TEST_SOURCE_QUICK) return [];
|
||||
if (source === TEST_SOURCE_ALL) return getComplianceDatasetPrompts();
|
||||
const fw = getFrameworks().find((f) => f.name === source);
|
||||
return fw ? fw.categories.flatMap((c) => c.prompts) : [];
|
||||
}
|
||||
ACTION_OPTIONS,
|
||||
ACTION_LABELS,
|
||||
createDefaultStep,
|
||||
insertStep,
|
||||
removeStep,
|
||||
updateStepAtIndex,
|
||||
derivePipelineFromPolicy,
|
||||
GuardrailIcon,
|
||||
PlayIcon,
|
||||
PassIcon,
|
||||
FailIcon,
|
||||
Connector,
|
||||
} from "./pipeline_utils";
|
||||
import { PipelineTestPanel } from "./pipeline_test_drawer";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ label: "Next Step", value: "next" },
|
||||
{ label: "Allow", value: "allow" },
|
||||
{ label: "Block", value: "block" },
|
||||
{ label: "Custom Response", value: "modify_response" },
|
||||
];
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
allow: "Allow",
|
||||
block: "Block",
|
||||
next: "Next Step",
|
||||
modify_response: "Custom Response",
|
||||
};
|
||||
|
||||
function createDefaultStep(): PipelineStep {
|
||||
return {
|
||||
guardrail: "",
|
||||
on_pass: "next",
|
||||
on_fail: "block",
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
};
|
||||
}
|
||||
|
||||
function insertStep(steps: PipelineStep[], atIndex: number): PipelineStep[] {
|
||||
const newSteps = [...steps];
|
||||
newSteps.splice(atIndex, 0, createDefaultStep());
|
||||
return newSteps;
|
||||
}
|
||||
|
||||
function removeStep(steps: PipelineStep[], index: number): PipelineStep[] {
|
||||
if (steps.length <= 1) return steps;
|
||||
const newSteps = [...steps];
|
||||
newSteps.splice(index, 1);
|
||||
return newSteps;
|
||||
}
|
||||
|
||||
function updateStepAtIndex(
|
||||
steps: PipelineStep[],
|
||||
index: number,
|
||||
updated: Partial<PipelineStep>
|
||||
): PipelineStep[] {
|
||||
return steps.map((s, i) => (i === index ? { ...s, ...updated } : s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a pipeline from a policy. When the policy has a pipeline, use it.
|
||||
* When it only has guardrails_add (legacy/simple form), convert those guardrails
|
||||
* into pipeline steps in order.
|
||||
*/
|
||||
function derivePipelineFromPolicy(policy: Policy | null | undefined): GuardrailPipeline {
|
||||
if (!policy) {
|
||||
return { mode: "pre_call", steps: [createDefaultStep()] };
|
||||
}
|
||||
if (policy.pipeline?.steps?.length) {
|
||||
return policy.pipeline;
|
||||
}
|
||||
const guardrails = policy.guardrails_add || [];
|
||||
if (guardrails.length > 0) {
|
||||
return {
|
||||
mode: policy.pipeline?.mode ?? "pre_call",
|
||||
steps: guardrails.map((g) => ({
|
||||
guardrail: g,
|
||||
on_pass: "next" as const,
|
||||
on_fail: "block" as const,
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { mode: "pre_call", steps: [createDefaultStep()] };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Icons (matching the reference image)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const GuardrailIcon: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#eef2ff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PlayIcon: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#f3f4f6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="#6b7280" stroke="none">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PassIcon: React.FC = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FailIcon: React.FC = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Connector
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorProps {
|
||||
onInsert: () => void;
|
||||
}
|
||||
|
||||
const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
|
||||
<div className="flex flex-col items-center" style={{ height: 56 }}>
|
||||
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
|
||||
<button
|
||||
onClick={onInsert}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "#fff",
|
||||
cursor: "pointer",
|
||||
zIndex: 1,
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#6366f1";
|
||||
e.currentTarget.style.backgroundColor = "#eef2ff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.backgroundColor = "#fff";
|
||||
}}
|
||||
title="Insert step"
|
||||
>
|
||||
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
|
||||
</button>
|
||||
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step Card (editable)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -581,432 +412,6 @@ export const PipelineInfoDisplay: React.FC<PipelineInfoDisplayProps> = ({ pipeli
|
|||
</div>
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pipeline Test Panel (right drawer)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PipelineTestPanelProps {
|
||||
pipeline: GuardrailPipeline;
|
||||
accessToken: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const OUTCOME_STYLES: Record<string, { bg: string; color: string; label: string }> = {
|
||||
pass: { bg: "#f0fdf4", color: "#16a34a", label: "PASS" },
|
||||
fail: { bg: "#fef2f2", color: "#dc2626", label: "FAIL" },
|
||||
error: { bg: "#fffbeb", color: "#d97706", label: "ERROR" },
|
||||
};
|
||||
|
||||
const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
allow: { bg: "#f0fdf4", color: "#16a34a" },
|
||||
block: { bg: "#fef2f2", color: "#dc2626" },
|
||||
modify_response: { bg: "#eff6ff", color: "#2563eb" },
|
||||
};
|
||||
|
||||
interface ComplianceRunEntry {
|
||||
prompt: CompliancePrompt;
|
||||
result: PipelineTestResult | null;
|
||||
error?: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
function complianceMatchExpected(expected: "pass" | "fail", terminalAction: string): boolean {
|
||||
if (expected === "pass") {
|
||||
return terminalAction === "allow" || terminalAction === "modify_response";
|
||||
}
|
||||
return terminalAction === "block";
|
||||
}
|
||||
|
||||
const testSourceOptions = [
|
||||
{ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" },
|
||||
...getFrameworks().map((f) => ({ value: f.name, label: f.name })),
|
||||
{ value: TEST_SOURCE_ALL, label: "All compliance datasets" },
|
||||
];
|
||||
|
||||
const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
||||
pipeline,
|
||||
accessToken,
|
||||
onClose,
|
||||
}) => {
|
||||
const [testSource, setTestSource] = useState<string>(TEST_SOURCE_QUICK);
|
||||
const [testMessage, setTestMessage] = useState("Hello, can you help me?");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<PipelineTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [complianceResults, setComplianceResults] = useState<ComplianceRunEntry[]>([]);
|
||||
|
||||
const isQuickChat = testSource === TEST_SOURCE_QUICK;
|
||||
const promptsForSource = getPromptsForTestSource(testSource);
|
||||
const isDataset = promptsForSource.length > 0;
|
||||
|
||||
const handleRunTest = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
|
||||
if (emptySteps.length > 0) {
|
||||
setError("All steps must have a guardrail selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsRunning(true);
|
||||
setResult(null);
|
||||
setComplianceResults([]);
|
||||
|
||||
if (isQuickChat) {
|
||||
try {
|
||||
const data = await testPipelineCall(
|
||||
accessToken,
|
||||
pipeline,
|
||||
[{ role: "user", content: testMessage }]
|
||||
);
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: ComplianceRunEntry[] = [];
|
||||
for (const prompt of promptsForSource) {
|
||||
try {
|
||||
const data = await testPipelineCall(accessToken, pipeline, [
|
||||
{ role: "user", content: prompt.prompt },
|
||||
]);
|
||||
const matched = complianceMatchExpected(prompt.expectedResult, data.terminal_action);
|
||||
entries.push({ prompt, result: data, matched });
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
entries.push({
|
||||
prompt,
|
||||
result: null,
|
||||
error: errMsg,
|
||||
matched: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
setComplianceResults(entries);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 400,
|
||||
borderLeft: "1px solid #e5e7eb",
|
||||
backgroundColor: "#fff",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Test Pipeline</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "#9ca3af",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input section */}
|
||||
<div style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
|
||||
<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"
|
||||
/>
|
||||
{isQuickChat && (
|
||||
<>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value)}
|
||||
placeholder="Enter a test message..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isDataset && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
padding: "8px 10px",
|
||||
backgroundColor: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{testSource === TEST_SOURCE_ALL
|
||||
? "Run pipeline against all compliance prompts (EU AI Act, GDPR, Topic Blocking, Airline, etc.)."
|
||||
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRunTest}
|
||||
loading={isRunning}
|
||||
style={{ marginTop: 8, width: "100%" }}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results section */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
backgroundColor: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: "#dc2626",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div>
|
||||
{/* Step results */}
|
||||
{result.step_results.map((step, i) => {
|
||||
const style = OUTCOME_STYLES[step.outcome] || OUTCOME_STYLES.error;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
|
||||
Step {i + 1}: {step.guardrail_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
backgroundColor: style.bg,
|
||||
color: style.color,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||
Action: {ACTION_LABELS[step.action_taken] || step.action_taken}
|
||||
{step.duration_seconds != null && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
({(step.duration_seconds * 1000).toFixed(0)}ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.error_detail && (
|
||||
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
|
||||
{step.error_detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Terminal result */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
paddingTop: 12,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>Result</span>
|
||||
{(() => {
|
||||
const ts = TERMINAL_STYLES[result.terminal_action] || TERMINAL_STYLES.block;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
backgroundColor: ts.bg,
|
||||
color: ts.color,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 4,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{result.terminal_action === "modify_response" ? "Custom Response" : result.terminal_action}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 6 }}>
|
||||
{result.error_message}
|
||||
</div>
|
||||
)}
|
||||
{result.modify_response_message && (
|
||||
<div style={{ fontSize: 12, color: "#2563eb", marginTop: 6 }}>
|
||||
Response: {result.modify_response_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{complianceResults.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#111827",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Compliance dataset
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{complianceResults.filter((e) => e.matched).length} / {complianceResults.length} matched
|
||||
expected
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{complianceResults.map((entry, i) => {
|
||||
const actual =
|
||||
entry.result?.terminal_action ?? (entry.error ? "error" : "—");
|
||||
const matchStyle = entry.matched
|
||||
? { bg: "#f0fdf4", color: "#16a34a" }
|
||||
: { bg: "#fef2f2", color: "#dc2626" };
|
||||
return (
|
||||
<div
|
||||
key={entry.prompt.id ?? i}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
borderBottom:
|
||||
i < complianceResults.length - 1
|
||||
? "1px solid #e5e7eb"
|
||||
: "none",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#374151",
|
||||
marginBottom: 4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={entry.prompt.prompt}
|
||||
>
|
||||
{entry.prompt.prompt}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
expected: {entry.prompt.expectedResult}
|
||||
</span>
|
||||
<span style={{ color: "#9ca3af" }}>→</span>
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
actual: {actual}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: matchStyle.bg,
|
||||
color: matchStyle.color,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{entry.matched ? "✓" : "✗"}
|
||||
</span>
|
||||
</div>
|
||||
{entry.error && (
|
||||
<div style={{ color: "#dc2626", marginTop: 4 }}>
|
||||
{entry.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !error && complianceResults.length === 0 && (
|
||||
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
|
||||
Choose a test source above (quick chat or a compliance dataset) and click "Run Test"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Policy Versions Sidebar (left sidebar when editing a policy)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
import { screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PipelineTestPanel } from "./pipeline_test_drawer";
|
||||
import type { GuardrailPipeline, PipelineTestResult } from "./types";
|
||||
import * as networking from "../networking";
|
||||
|
||||
vi.mock("../networking");
|
||||
vi.mock("../../data/compliancePrompts", () => ({
|
||||
getFrameworks: () => [
|
||||
{
|
||||
name: "TestFW",
|
||||
icon: "",
|
||||
description: "",
|
||||
categories: [
|
||||
{
|
||||
name: "cat",
|
||||
icon: "",
|
||||
description: "",
|
||||
prompts: [
|
||||
{
|
||||
id: "cp-1",
|
||||
framework: "TestFW",
|
||||
category: "cat",
|
||||
categoryIcon: "",
|
||||
categoryDescription: "",
|
||||
prompt: "Is this safe?",
|
||||
expectedResult: "pass",
|
||||
},
|
||||
{
|
||||
id: "cp-2",
|
||||
framework: "TestFW",
|
||||
category: "cat",
|
||||
categoryIcon: "",
|
||||
categoryDescription: "",
|
||||
prompt: "How to hack a server?",
|
||||
expectedResult: "fail",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
getComplianceDatasetPrompts: () => [],
|
||||
}));
|
||||
|
||||
const validPipeline: GuardrailPipeline = {
|
||||
mode: "pre_call",
|
||||
steps: [
|
||||
{
|
||||
guardrail: "content-filter",
|
||||
on_pass: "allow",
|
||||
on_fail: "block",
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptyGuardrailPipeline: GuardrailPipeline = {
|
||||
mode: "pre_call",
|
||||
steps: [
|
||||
{
|
||||
guardrail: "",
|
||||
on_pass: "next",
|
||||
on_fail: "block",
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockTestResult: PipelineTestResult = {
|
||||
terminal_action: "allow",
|
||||
step_results: [
|
||||
{
|
||||
guardrail_name: "content-filter",
|
||||
outcome: "pass",
|
||||
action_taken: "allow",
|
||||
modified_data: null,
|
||||
error_detail: null,
|
||||
duration_seconds: 0.123,
|
||||
},
|
||||
],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
pipeline: validPipeline,
|
||||
accessToken: "test-token",
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
async function selectAntOption(user: ReturnType<typeof userEvent.setup>, title: string) {
|
||||
const selector = document.querySelector(".ant-select-selector");
|
||||
if (!selector) throw new Error("Ant Select selector not found");
|
||||
await user.click(selector);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(`[title="${title}"].ant-select-item-option`)).toBeInTheDocument();
|
||||
});
|
||||
const option = document.querySelector(`[title="${title}"].ant-select-item-option`);
|
||||
if (!option) throw new Error(`Ant Select option "${title}" not found`);
|
||||
await user.click(option);
|
||||
}
|
||||
|
||||
describe("PipelineTestPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Test Pipeline")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when the close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
await user.click(screen.getByRole("button", { name: /x/i }));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show the default test message textarea", () => {
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText("Enter a test message...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show placeholder text before any test is run", () => {
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
expect(screen.getByText(/choose a test source above/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show an error when a step has no guardrail selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<PipelineTestPanel
|
||||
{...defaultProps}
|
||||
pipeline={emptyGuardrailPipeline}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
expect(screen.getByText("All steps must have a guardrail selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display step results after a successful test", async () => {
|
||||
vi.mocked(networking.testPipelineCall).mockResolvedValue(mockTestResult);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Step 1: content-filter/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("PASS")).toBeInTheDocument();
|
||||
expect(screen.getByText(/123ms/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the terminal action after a successful test", async () => {
|
||||
vi.mocked(networking.testPipelineCall).mockResolvedValue(mockTestResult);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("allow")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display 'Custom Response' for modify_response terminal action", async () => {
|
||||
const modifyResult: PipelineTestResult = {
|
||||
...mockTestResult,
|
||||
terminal_action: "modify_response",
|
||||
modify_response_message: "Sorry, I can't help with that.",
|
||||
};
|
||||
vi.mocked(networking.testPipelineCall).mockResolvedValue(modifyResult);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Custom Response")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Sorry, I can't help with that/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display an error when the API call fails", async () => {
|
||||
vi.mocked(networking.testPipelineCall).mockRejectedValue(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call the API when accessToken is null", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<PipelineTestPanel {...defaultProps} accessToken={null} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
expect(networking.testPipelineCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show step error detail when present", async () => {
|
||||
const resultWithError: PipelineTestResult = {
|
||||
terminal_action: "block",
|
||||
step_results: [
|
||||
{
|
||||
guardrail_name: "content-filter",
|
||||
outcome: "error",
|
||||
action_taken: "block",
|
||||
modified_data: null,
|
||||
error_detail: "Guardrail timed out",
|
||||
duration_seconds: null,
|
||||
},
|
||||
],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
};
|
||||
vi.mocked(networking.testPipelineCall).mockResolvedValue(resultWithError);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Guardrail timed out")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PipelineTestPanel compliance-dataset mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should show matched/total count after running a compliance dataset", async () => {
|
||||
const allowResult: PipelineTestResult = {
|
||||
terminal_action: "allow",
|
||||
step_results: [],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
};
|
||||
const blockResult: PipelineTestResult = {
|
||||
terminal_action: "block",
|
||||
step_results: [],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
};
|
||||
vi.mocked(networking.testPipelineCall)
|
||||
.mockResolvedValueOnce(allowResult)
|
||||
.mockResolvedValueOnce(blockResult);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
await selectAntOption(user, "TestFW");
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 \/ 2 matched/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a mismatch indicator when actual result differs from expected", async () => {
|
||||
const blockResult: PipelineTestResult = {
|
||||
terminal_action: "block",
|
||||
step_results: [],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
};
|
||||
// Both prompts return "block": pass-expected prompt mismatches, fail-expected matches
|
||||
vi.mocked(networking.testPipelineCall)
|
||||
.mockResolvedValueOnce(blockResult)
|
||||
.mockResolvedValueOnce(blockResult);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
await selectAntOption(user, "TestFW");
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 \/ 2 matched/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display per-prompt error when an API call fails in dataset mode", async () => {
|
||||
vi.mocked(networking.testPipelineCall)
|
||||
.mockRejectedValueOnce(new Error("Timeout on prompt 1"))
|
||||
.mockResolvedValueOnce({
|
||||
terminal_action: "block",
|
||||
step_results: [],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
await selectAntOption(user, "TestFW");
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Timeout on prompt 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/1 \/ 2 matched/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call testPipelineCall once per prompt in the dataset", async () => {
|
||||
const allowResult: PipelineTestResult = {
|
||||
terminal_action: "allow",
|
||||
step_results: [],
|
||||
modified_data: null,
|
||||
error_message: null,
|
||||
modify_response_message: null,
|
||||
};
|
||||
vi.mocked(networking.testPipelineCall).mockResolvedValue(allowResult);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
|
||||
await selectAntOption(user, "TestFW");
|
||||
await user.click(screen.getByRole("button", { name: /run test/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.testPipelineCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
import { GuardrailPipeline, PipelineStep, PipelineTestResult } from "./types";
|
||||
import { testPipelineCall } from "../networking";
|
||||
import type { CompliancePrompt } from "../../data/compliancePrompts";
|
||||
import {
|
||||
TEST_SOURCE_QUICK,
|
||||
TEST_SOURCE_ALL,
|
||||
ACTION_LABELS,
|
||||
getPromptsForTestSource,
|
||||
getTestSourceOptions,
|
||||
complianceMatchExpected,
|
||||
} from "./pipeline_utils";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Style maps
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_STYLES: Record<string, { bg: string; color: string; label: string }> = {
|
||||
pass: { bg: "#f0fdf4", color: "#16a34a", label: "PASS" },
|
||||
fail: { bg: "#fef2f2", color: "#dc2626", label: "FAIL" },
|
||||
error: { bg: "#fffbeb", color: "#d97706", label: "ERROR" },
|
||||
};
|
||||
|
||||
const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
allow: { bg: "#f0fdf4", color: "#16a34a" },
|
||||
block: { bg: "#fef2f2", color: "#dc2626" },
|
||||
modify_response: { bg: "#eff6ff", color: "#2563eb" },
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ComplianceRunEntry {
|
||||
prompt: CompliancePrompt;
|
||||
result: PipelineTestResult | null;
|
||||
error?: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PipelineTestPanel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PipelineTestPanelProps {
|
||||
pipeline: GuardrailPipeline;
|
||||
accessToken: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
||||
pipeline,
|
||||
accessToken,
|
||||
onClose,
|
||||
}) => {
|
||||
const [testSource, setTestSource] = useState<string>(TEST_SOURCE_QUICK);
|
||||
const [testMessage, setTestMessage] = useState("Hello, can you help me?");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<PipelineTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [complianceResults, setComplianceResults] = useState<ComplianceRunEntry[]>([]);
|
||||
|
||||
const testSourceOptions = useMemo(() => getTestSourceOptions(), []);
|
||||
|
||||
const isQuickChat = testSource === TEST_SOURCE_QUICK;
|
||||
const promptsForSource = useMemo(() => getPromptsForTestSource(testSource), [testSource]);
|
||||
const isDataset = promptsForSource.length > 0;
|
||||
|
||||
const handleRunTest = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
|
||||
if (emptySteps.length > 0) {
|
||||
setError("All steps must have a guardrail selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsRunning(true);
|
||||
setResult(null);
|
||||
setComplianceResults([]);
|
||||
|
||||
if (isQuickChat) {
|
||||
try {
|
||||
const data = await testPipelineCall(
|
||||
accessToken,
|
||||
pipeline,
|
||||
[{ role: "user", content: testMessage }]
|
||||
);
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: ComplianceRunEntry[] = [];
|
||||
try {
|
||||
for (const prompt of promptsForSource) {
|
||||
try {
|
||||
const data = await testPipelineCall(accessToken, pipeline, [
|
||||
{ role: "user", content: prompt.prompt },
|
||||
]);
|
||||
const matched = complianceMatchExpected(prompt.expectedResult, data.terminal_action);
|
||||
entries.push({ prompt, result: data, matched });
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
entries.push({
|
||||
prompt,
|
||||
result: null,
|
||||
error: errMsg,
|
||||
matched: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
setComplianceResults(entries);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 400,
|
||||
borderLeft: "1px solid #e5e7eb",
|
||||
backgroundColor: "#fff",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Test Pipeline</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "#9ca3af",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input section */}
|
||||
<div style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
|
||||
<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"
|
||||
/>
|
||||
{isQuickChat && (
|
||||
<>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value)}
|
||||
placeholder="Enter a test message..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isDataset && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
padding: "8px 10px",
|
||||
backgroundColor: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{testSource === TEST_SOURCE_ALL
|
||||
? "Run pipeline against all compliance prompts (EU AI Act, GDPR, Topic Blocking, Airline, etc.)."
|
||||
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRunTest}
|
||||
loading={isRunning}
|
||||
style={{ marginTop: 8, width: "100%" }}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results section */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
backgroundColor: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: "#dc2626",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div>
|
||||
{/* Step results */}
|
||||
{result.step_results.map((step, i) => {
|
||||
const style = OUTCOME_STYLES[step.outcome] || OUTCOME_STYLES.error;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
|
||||
Step {i + 1}: {step.guardrail_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
backgroundColor: style.bg,
|
||||
color: style.color,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||
Action: {ACTION_LABELS[step.action_taken] || step.action_taken}
|
||||
{step.duration_seconds != null && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
({(step.duration_seconds * 1000).toFixed(0)}ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.error_detail && (
|
||||
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
|
||||
{step.error_detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Terminal result */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
paddingTop: 12,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>Result</span>
|
||||
{(() => {
|
||||
const ts = TERMINAL_STYLES[result.terminal_action] || TERMINAL_STYLES.block;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
backgroundColor: ts.bg,
|
||||
color: ts.color,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 4,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{result.terminal_action === "modify_response" ? "Custom Response" : result.terminal_action}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 6 }}>
|
||||
{result.error_message}
|
||||
</div>
|
||||
)}
|
||||
{result.modify_response_message && (
|
||||
<div style={{ fontSize: 12, color: "#2563eb", marginTop: 6 }}>
|
||||
Response: {result.modify_response_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{complianceResults.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#111827",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Compliance dataset
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{complianceResults.filter((e) => e.matched).length} / {complianceResults.length} matched
|
||||
expected
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{complianceResults.map((entry, i) => {
|
||||
const actual =
|
||||
entry.result?.terminal_action ?? (entry.error ? "error" : "—");
|
||||
const matchStyle = entry.matched
|
||||
? { bg: "#f0fdf4", color: "#16a34a" }
|
||||
: { bg: "#fef2f2", color: "#dc2626" };
|
||||
return (
|
||||
<div
|
||||
key={entry.prompt.id ?? i}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
borderBottom:
|
||||
i < complianceResults.length - 1
|
||||
? "1px solid #e5e7eb"
|
||||
: "none",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#374151",
|
||||
marginBottom: 4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={entry.prompt.prompt}
|
||||
>
|
||||
{entry.prompt.prompt}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
expected: {entry.prompt.expectedResult}
|
||||
</span>
|
||||
<span style={{ color: "#9ca3af" }}>→</span>
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
actual: {actual}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: matchStyle.bg,
|
||||
color: matchStyle.color,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{entry.matched ? "✓" : "✗"}
|
||||
</span>
|
||||
</div>
|
||||
{entry.error && (
|
||||
<div style={{ color: "#dc2626", marginTop: 4 }}>
|
||||
{entry.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !error && complianceResults.length === 0 && (
|
||||
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
|
||||
Choose a test source above (quick chat or a compliance dataset) and click "Run Test"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDefaultStep,
|
||||
insertStep,
|
||||
removeStep,
|
||||
updateStepAtIndex,
|
||||
derivePipelineFromPolicy,
|
||||
complianceMatchExpected,
|
||||
getPromptsForTestSource,
|
||||
getTestSourceOptions,
|
||||
TEST_SOURCE_QUICK,
|
||||
TEST_SOURCE_ALL,
|
||||
} from "./pipeline_utils";
|
||||
import type { PipelineStep, Policy } from "./types";
|
||||
import * as complianceData from "../../data/compliancePrompts";
|
||||
|
||||
const makeStep = (guardrail: string, overrides?: Partial<PipelineStep>): PipelineStep => ({
|
||||
guardrail,
|
||||
on_pass: "next",
|
||||
on_fail: "block",
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("createDefaultStep", () => {
|
||||
it("should return a step with empty guardrail and default actions", () => {
|
||||
const step = createDefaultStep();
|
||||
expect(step).toEqual({
|
||||
guardrail: "",
|
||||
on_pass: "next",
|
||||
on_fail: "block",
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertStep", () => {
|
||||
it("should insert a default step at the given index", () => {
|
||||
const steps = [makeStep("a"), makeStep("b")];
|
||||
const result = insertStep(steps, 1);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1].guardrail).toBe("");
|
||||
expect(result[0].guardrail).toBe("a");
|
||||
expect(result[2].guardrail).toBe("b");
|
||||
});
|
||||
|
||||
it("should insert at the beginning when index is 0", () => {
|
||||
const steps = [makeStep("a")];
|
||||
const result = insertStep(steps, 0);
|
||||
expect(result[0].guardrail).toBe("");
|
||||
expect(result[1].guardrail).toBe("a");
|
||||
});
|
||||
|
||||
it("should not mutate the original array", () => {
|
||||
const steps = [makeStep("a")];
|
||||
const result = insertStep(steps, 0);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStep", () => {
|
||||
it("should remove the step at the given index", () => {
|
||||
const steps = [makeStep("a"), makeStep("b"), makeStep("c")];
|
||||
const result = removeStep(steps, 1);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((s) => s.guardrail)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("should not remove if only one step remains", () => {
|
||||
const steps = [makeStep("a")];
|
||||
const result = removeStep(steps, 0);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].guardrail).toBe("a");
|
||||
});
|
||||
|
||||
it("should not mutate the original array", () => {
|
||||
const steps = [makeStep("a"), makeStep("b")];
|
||||
removeStep(steps, 0);
|
||||
expect(steps).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateStepAtIndex", () => {
|
||||
it("should update only the step at the target index", () => {
|
||||
const steps = [makeStep("a"), makeStep("b")];
|
||||
const result = updateStepAtIndex(steps, 1, { guardrail: "updated" });
|
||||
expect(result[0].guardrail).toBe("a");
|
||||
expect(result[1].guardrail).toBe("updated");
|
||||
});
|
||||
|
||||
it("should merge partial updates into the existing step", () => {
|
||||
const steps = [makeStep("a", { on_pass: "allow" })];
|
||||
const result = updateStepAtIndex(steps, 0, { on_fail: "allow" });
|
||||
expect(result[0].on_pass).toBe("allow");
|
||||
expect(result[0].on_fail).toBe("allow");
|
||||
expect(result[0].guardrail).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePipelineFromPolicy", () => {
|
||||
it("should return a default pipeline when policy is null", () => {
|
||||
const pipeline = derivePipelineFromPolicy(null);
|
||||
expect(pipeline.mode).toBe("pre_call");
|
||||
expect(pipeline.steps).toHaveLength(1);
|
||||
expect(pipeline.steps[0].guardrail).toBe("");
|
||||
});
|
||||
|
||||
it("should return a default pipeline when policy is undefined", () => {
|
||||
const pipeline = derivePipelineFromPolicy(undefined);
|
||||
expect(pipeline.mode).toBe("pre_call");
|
||||
expect(pipeline.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should use the existing pipeline when present", () => {
|
||||
const policy: Policy = {
|
||||
policy_id: "p1",
|
||||
policy_name: "test",
|
||||
inherit: null,
|
||||
description: null,
|
||||
guardrails_add: [],
|
||||
guardrails_remove: [],
|
||||
condition: null,
|
||||
pipeline: {
|
||||
mode: "post_call",
|
||||
steps: [makeStep("existing-guardrail")],
|
||||
},
|
||||
};
|
||||
const pipeline = derivePipelineFromPolicy(policy);
|
||||
expect(pipeline.mode).toBe("post_call");
|
||||
expect(pipeline.steps[0].guardrail).toBe("existing-guardrail");
|
||||
});
|
||||
|
||||
it("should convert guardrails_add to pipeline steps when no pipeline exists", () => {
|
||||
const policy: Policy = {
|
||||
policy_id: "p1",
|
||||
policy_name: "test",
|
||||
inherit: null,
|
||||
description: null,
|
||||
guardrails_add: ["gA", "gB"],
|
||||
guardrails_remove: [],
|
||||
condition: null,
|
||||
};
|
||||
const pipeline = derivePipelineFromPolicy(policy);
|
||||
expect(pipeline.steps).toHaveLength(2);
|
||||
expect(pipeline.steps[0].guardrail).toBe("gA");
|
||||
expect(pipeline.steps[1].guardrail).toBe("gB");
|
||||
expect(pipeline.steps[0].on_pass).toBe("next");
|
||||
expect(pipeline.steps[0].on_fail).toBe("block");
|
||||
});
|
||||
|
||||
it("should return a default pipeline when policy has no pipeline and no guardrails", () => {
|
||||
const policy: Policy = {
|
||||
policy_id: "p1",
|
||||
policy_name: "test",
|
||||
inherit: null,
|
||||
description: null,
|
||||
guardrails_add: [],
|
||||
guardrails_remove: [],
|
||||
condition: null,
|
||||
};
|
||||
const pipeline = derivePipelineFromPolicy(policy);
|
||||
expect(pipeline.steps).toHaveLength(1);
|
||||
expect(pipeline.steps[0].guardrail).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complianceMatchExpected", () => {
|
||||
it("should match 'pass' expectation with 'allow' action", () => {
|
||||
expect(complianceMatchExpected("pass", "allow")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match 'pass' expectation with 'modify_response' action", () => {
|
||||
expect(complianceMatchExpected("pass", "modify_response")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match 'pass' expectation with 'block' action", () => {
|
||||
expect(complianceMatchExpected("pass", "block")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match 'fail' expectation with 'block' action", () => {
|
||||
expect(complianceMatchExpected("fail", "block")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match 'fail' expectation with 'allow' action", () => {
|
||||
expect(complianceMatchExpected("fail", "allow")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPromptsForTestSource", () => {
|
||||
it("should return an empty array for quick_chat source", () => {
|
||||
expect(getPromptsForTestSource(TEST_SOURCE_QUICK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all compliance prompts for __all__ source", () => {
|
||||
const mockPrompts = [
|
||||
{ id: "p1", framework: "fw", category: "c", categoryIcon: "", categoryDescription: "", prompt: "test", expectedResult: "pass" as const },
|
||||
];
|
||||
vi.spyOn(complianceData, "getComplianceDatasetPrompts").mockReturnValue(mockPrompts);
|
||||
const result = getPromptsForTestSource(TEST_SOURCE_ALL);
|
||||
expect(result).toEqual(mockPrompts);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return prompts for a matching framework name", () => {
|
||||
const prompt = { id: "p1", framework: "GDPR", category: "c", categoryIcon: "", categoryDescription: "", prompt: "test", expectedResult: "fail" as const };
|
||||
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([
|
||||
{ name: "GDPR", icon: "", description: "", categories: [{ name: "c", icon: "", description: "", prompts: [prompt] }] },
|
||||
]);
|
||||
const result = getPromptsForTestSource("GDPR");
|
||||
expect(result).toEqual([prompt]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return an empty array for an unrecognized source", () => {
|
||||
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([]);
|
||||
expect(getPromptsForTestSource("nonexistent")).toEqual([]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTestSourceOptions", () => {
|
||||
it("should always include quick chat and all datasets options", () => {
|
||||
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([]);
|
||||
const options = getTestSourceOptions();
|
||||
expect(options[0]).toEqual({ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" });
|
||||
expect(options[options.length - 1]).toEqual({ value: TEST_SOURCE_ALL, label: "All compliance datasets" });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should include framework names from getFrameworks", () => {
|
||||
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([
|
||||
{ name: "GDPR", icon: "", description: "", categories: [] },
|
||||
{ name: "EU AI Act", icon: "", description: "", categories: [] },
|
||||
]);
|
||||
const options = getTestSourceOptions();
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options[1]).toEqual({ value: "GDPR", label: "GDPR" });
|
||||
expect(options[2]).toEqual({ value: "EU AI Act", label: "EU AI Act" });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
210
ui/litellm-dashboard/src/components/policies/pipeline_utils.tsx
Normal file
210
ui/litellm-dashboard/src/components/policies/pipeline_utils.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import React from "react";
|
||||
import { PlusIcon } from "@heroicons/react/outline";
|
||||
import { PipelineStep, GuardrailPipeline } from "./types";
|
||||
import type { CompliancePrompt } from "../../data/compliancePrompts";
|
||||
import { getComplianceDatasetPrompts, getFrameworks } from "../../data/compliancePrompts";
|
||||
import { Policy } from "./types";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const TEST_SOURCE_QUICK = "quick_chat";
|
||||
export const TEST_SOURCE_ALL = "__all__";
|
||||
|
||||
export const ACTION_OPTIONS = [
|
||||
{ label: "Next Step", value: "next" },
|
||||
{ label: "Allow", value: "allow" },
|
||||
{ label: "Block", value: "block" },
|
||||
{ label: "Custom Response", value: "modify_response" },
|
||||
];
|
||||
|
||||
export const ACTION_LABELS: Record<string, string> = {
|
||||
allow: "Allow",
|
||||
block: "Block",
|
||||
next: "Next Step",
|
||||
modify_response: "Custom Response",
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utility functions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getPromptsForTestSource(source: string): CompliancePrompt[] {
|
||||
if (source === TEST_SOURCE_QUICK) return [];
|
||||
if (source === TEST_SOURCE_ALL) return getComplianceDatasetPrompts();
|
||||
const fw = getFrameworks().find((f) => f.name === source);
|
||||
return fw ? fw.categories.flatMap((c) => c.prompts) : [];
|
||||
}
|
||||
|
||||
export function getTestSourceOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" },
|
||||
...getFrameworks().map((f) => ({ value: f.name, label: f.name })),
|
||||
{ value: TEST_SOURCE_ALL, label: "All compliance datasets" },
|
||||
];
|
||||
}
|
||||
|
||||
export function createDefaultStep(): PipelineStep {
|
||||
return {
|
||||
guardrail: "",
|
||||
on_pass: "next",
|
||||
on_fail: "block",
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertStep(steps: PipelineStep[], atIndex: number): PipelineStep[] {
|
||||
const newSteps = [...steps];
|
||||
newSteps.splice(atIndex, 0, createDefaultStep());
|
||||
return newSteps;
|
||||
}
|
||||
|
||||
export function removeStep(steps: PipelineStep[], index: number): PipelineStep[] {
|
||||
if (steps.length <= 1) return steps;
|
||||
const newSteps = [...steps];
|
||||
newSteps.splice(index, 1);
|
||||
return newSteps;
|
||||
}
|
||||
|
||||
export function updateStepAtIndex(
|
||||
steps: PipelineStep[],
|
||||
index: number,
|
||||
updated: Partial<PipelineStep>
|
||||
): PipelineStep[] {
|
||||
return steps.map((s, i) => (i === index ? { ...s, ...updated } : s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a pipeline from a policy. When the policy has a pipeline, use it.
|
||||
* When it only has guardrails_add (legacy/simple form), convert those guardrails
|
||||
* into pipeline steps in order.
|
||||
*/
|
||||
export function derivePipelineFromPolicy(policy: Policy | null | undefined): GuardrailPipeline {
|
||||
if (!policy) {
|
||||
return { mode: "pre_call", steps: [createDefaultStep()] };
|
||||
}
|
||||
if (policy.pipeline?.steps?.length) {
|
||||
return policy.pipeline;
|
||||
}
|
||||
const guardrails = policy.guardrails_add || [];
|
||||
if (guardrails.length > 0) {
|
||||
return {
|
||||
mode: policy.pipeline?.mode ?? "pre_call",
|
||||
steps: guardrails.map((g) => ({
|
||||
guardrail: g,
|
||||
on_pass: "next" as const,
|
||||
on_fail: "block" as const,
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { mode: "pre_call", steps: [createDefaultStep()] };
|
||||
}
|
||||
|
||||
export function complianceMatchExpected(expected: "pass" | "fail", terminalAction: string): boolean {
|
||||
if (expected === "pass") {
|
||||
return terminalAction === "allow" || terminalAction === "modify_response";
|
||||
}
|
||||
return terminalAction === "block";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Icons
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GuardrailIcon: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#eef2ff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PlayIcon: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#f3f4f6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="#6b7280" stroke="none">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PassIcon: React.FC = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FailIcon: React.FC = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M9 9l6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Connector
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorProps {
|
||||
onInsert: () => void;
|
||||
}
|
||||
|
||||
export const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
|
||||
<div className="flex flex-col items-center" style={{ height: 56 }}>
|
||||
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
|
||||
<button
|
||||
onClick={onInsert}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "#fff",
|
||||
cursor: "pointer",
|
||||
zIndex: 1,
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#6366f1";
|
||||
e.currentTarget.style.backgroundColor = "#eef2ff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.backgroundColor = "#fff";
|
||||
}}
|
||||
title="Insert step"
|
||||
>
|
||||
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
|
||||
</button>
|
||||
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
|
||||
</div>
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue