mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat: redesign GuardrailViewer to Guardrails & Policy Compliance layout
Two-column layout with request lifecycle timeline on the left and compact evaluation detail cards on the right. Header shows guardrail count, pass/fail status, total overhead, policy info, and an export button.
This commit is contained in:
parent
d648e0fa4a
commit
07bab6dc34
1 changed files with 541 additions and 356 deletions
|
|
@ -1,11 +1,13 @@
|
|||
import React, { useState } from "react";
|
||||
import { Tooltip, Collapse } from "antd";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
} from "@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails";
|
||||
import ContentFilterDetails from "./ContentFilterDetails";
|
||||
|
||||
// ── Interfaces ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
recognizer_identifier: string;
|
||||
|
|
@ -57,39 +59,172 @@ interface GuardrailViewerProps {
|
|||
data: GuardrailInformation | GuardrailInformation[];
|
||||
}
|
||||
|
||||
interface GuardrailDetailsProps {
|
||||
entry: GuardrailInformation;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set([
|
||||
"presidio",
|
||||
"bedrock",
|
||||
"litellm_content_filter",
|
||||
]);
|
||||
|
||||
const formatMode = (mode: string): string => {
|
||||
return mode.replace(/_/g, "-").toUpperCase();
|
||||
};
|
||||
|
||||
// Providers with custom renderers
|
||||
const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set(["presidio", "bedrock", "litellm_content_filter"]);
|
||||
const formatDurationMs = (seconds: number): string => {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return `${ms}ms`;
|
||||
};
|
||||
|
||||
const getTotalMasked = (entry: GuardrailInformation): number => {
|
||||
return Object.values(entry.masked_entity_count || {}).reduce(
|
||||
(sum, count) => sum + (typeof count === "number" ? count : 0),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const isEntrySuccess = (entry: GuardrailInformation): boolean => {
|
||||
return (entry.guardrail_status ?? "").toLowerCase() === "success";
|
||||
};
|
||||
|
||||
const getDisplayName = (entry: GuardrailInformation): string => {
|
||||
return entry.policy_template || entry.guardrail_name;
|
||||
};
|
||||
|
||||
// ── Icons (inline SVGs) ─────────────────────────────────────────────────────
|
||||
|
||||
const ShieldIcon = () => (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="20" fill="#EEF2FF" />
|
||||
<path
|
||||
d="M20 10l8 4v6c0 5.25-3.4 10.15-8 11.5C15.4 30.15 12 25.25 12 20v-6l8-4z"
|
||||
stroke="#6366F1"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M16 20l3 3 5-6"
|
||||
stroke="#6366F1"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckCircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" className={className}>
|
||||
<circle cx="11" cy="11" r="10" stroke="#16A34A" strokeWidth="1.5" fill="#F0FDF4" />
|
||||
<path d="M7 11l3 3 5-6" stroke="#16A34A" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FailCircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" className={className}>
|
||||
<circle cx="11" cy="11" r="10" stroke="#DC2626" strokeWidth="1.5" fill="#FEF2F2" />
|
||||
<path d="M8 8l6 6M14 8l-6 6" stroke="#DC2626" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlayCircleIcon = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<circle cx="11" cy="11" r="10" stroke="#3B82F6" strokeWidth="1.5" fill="#EFF6FF" />
|
||||
<path d="M9 7.5l6 3.5-6 3.5V7.5z" fill="#3B82F6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GrayDotIcon = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<circle cx="11" cy="11" r="5" fill="#9CA3AF" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className={`transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="M6 8l4 4 4-4" stroke="#6B7280" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 2v8m0 0l-3-3m3 3l3-3M3 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExternalLinkIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="inline ml-1">
|
||||
<path d="M6 2H3a1 1 0 00-1 1v8a1 1 0 001 1h8a1 1 0 001-1V8M8 2h4m0 0v4m0-4L6.5 7.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => {
|
||||
if (!matchDetails || matchDetails.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<h5 className="text-sm font-medium mb-2 text-gray-700">Match Details ({matchDetails.length})</h5>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 pr-4 font-medium">Type</th>
|
||||
<th className="pb-2 pr-4 font-medium">Method</th>
|
||||
<th className="pb-2 pr-4 font-medium">Action</th>
|
||||
<th className="pb-2 font-medium">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matchDetails.map((match, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-4">{match.type}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-700 rounded text-xs">
|
||||
{match.detection_method ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
match.action_taken === "BLOCK" ? "bg-red-100 text-red-800" : "bg-blue-50 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{match.action_taken ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-gray-600 break-all">
|
||||
{match.category ? `[${match.category}] ` : ""}
|
||||
{match.snippet ?? "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mt-3">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${showRaw ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h5 className="font-medium">Raw Guardrail Response</h5>
|
||||
<ChevronIcon expanded={showRaw} />
|
||||
<h5 className="font-medium text-sm ml-1">Raw Guardrail Response</h5>
|
||||
</div>
|
||||
</div>
|
||||
{showRaw && (
|
||||
|
|
@ -104,189 +239,161 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const PolicyDetectionRow = ({ entry }: { entry: GuardrailInformation }) => {
|
||||
const hasData = entry.policy_template || entry.detection_method || entry.confidence_score != null || entry.patterns_checked != null;
|
||||
if (!hasData) return null;
|
||||
// ── Timeline entry types ────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{entry.policy_template && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Policy:</span>
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 rounded-md text-xs font-medium">
|
||||
{entry.policy_template}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.detection_method && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Detection:</span>
|
||||
{entry.detection_method.split(",").map((method) => (
|
||||
<span key={method} className="px-2 py-1 bg-slate-100 text-slate-700 rounded-md text-xs font-medium">
|
||||
{method.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entry.confidence_score != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Confidence:</span>
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
entry.confidence_score >= 0.8 ? "bg-red-100 text-red-800" :
|
||||
entry.confidence_score >= 0.5 ? "bg-amber-100 text-amber-800" :
|
||||
"bg-green-100 text-green-800"
|
||||
}`}>
|
||||
{(entry.confidence_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.patterns_checked != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Patterns checked:</span>
|
||||
<span className="text-sm text-gray-600">{entry.patterns_checked}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
interface TimelineEntry {
|
||||
type: "request" | "guardrail" | "llm" | "response";
|
||||
label: string;
|
||||
offsetMs: number;
|
||||
status?: string;
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
||||
const sorted = useMemo(
|
||||
() => [...entries].sort((a, b) => (a.start_time ?? 0) - (b.start_time ?? 0)),
|
||||
[entries],
|
||||
);
|
||||
};
|
||||
|
||||
const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => {
|
||||
if (!matchDetails || matchDetails.length === 0) return null;
|
||||
const timeline = useMemo(() => {
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const baseTime = sorted[0].start_time;
|
||||
const items: TimelineEntry[] = [];
|
||||
|
||||
// Request received
|
||||
items.push({ type: "request", label: "Request received", offsetMs: 0 });
|
||||
|
||||
// Pre-call guardrails
|
||||
const preCalls = sorted.filter((e) => e.guardrail_mode === "pre_call");
|
||||
const postCalls = sorted.filter((e) => e.guardrail_mode === "post_call" || e.guardrail_mode === "logging_only");
|
||||
const duringCalls = sorted.filter((e) => e.guardrail_mode === "during_call");
|
||||
|
||||
for (const e of preCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Pre-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
|
||||
isSuccess: isEntrySuccess(e),
|
||||
});
|
||||
}
|
||||
|
||||
// LLM call — infer from gap between pre-call end and post-call start
|
||||
const lastPreEnd = preCalls.length > 0 ? Math.max(...preCalls.map((e) => e.end_time)) : baseTime;
|
||||
const firstPostStart = postCalls.length > 0 ? Math.min(...postCalls.map((e) => e.start_time)) : undefined;
|
||||
const llmEndTime = firstPostStart ?? (lastPreEnd + 1);
|
||||
const llmOffsetMs = Math.round((llmEndTime - baseTime) * 1000);
|
||||
|
||||
items.push({
|
||||
type: "llm",
|
||||
label: "LLM call",
|
||||
offsetMs: llmOffsetMs,
|
||||
});
|
||||
|
||||
// During-call guardrails (rare)
|
||||
for (const e of duringCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `During-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
|
||||
isSuccess: isEntrySuccess(e),
|
||||
});
|
||||
}
|
||||
|
||||
// Post-call guardrails
|
||||
for (const e of postCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Post-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
|
||||
isSuccess: isEntrySuccess(e),
|
||||
});
|
||||
}
|
||||
|
||||
// Response returned
|
||||
const maxEnd = Math.max(...sorted.map((e) => e.end_time));
|
||||
const responseOffsetMs = Math.round((maxEnd - baseTime) * 1000) + 1;
|
||||
items.push({ type: "response", label: "Response returned", offsetMs: responseOffsetMs });
|
||||
|
||||
return items;
|
||||
}, [sorted]);
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h5 className="font-medium mb-2">Match Details ({matchDetails.length})</h5>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 pr-4">Type</th>
|
||||
<th className="pb-2 pr-4">Method</th>
|
||||
<th className="pb-2 pr-4">Action</th>
|
||||
<th className="pb-2">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matchDetails.map((match, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-4">{match.type}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-700 rounded text-xs">
|
||||
{match.detection_method ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
match.action_taken === "BLOCK" ? "bg-red-100 text-red-800" : "bg-blue-50 text-blue-700"
|
||||
}`}>
|
||||
{match.action_taken ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-gray-600 break-all">
|
||||
{match.category ? `[${match.category}] ` : ""}{match.snippet ?? "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClassificationDetails = ({ classification }: { classification: Record<string, any> }) => {
|
||||
if (!classification) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h5 className="font-medium mb-2">Classification</h5>
|
||||
<div className="space-y-2 bg-gray-50 rounded-lg p-3">
|
||||
{classification.category && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3 text-sm">Category:</span>
|
||||
<span className="text-sm">{classification.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{classification.article_reference && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3 text-sm">Reference:</span>
|
||||
<span className="text-sm font-mono">{classification.article_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
{classification.confidence != null && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3 text-sm">Confidence:</span>
|
||||
<span className="text-sm">{(classification.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{classification.reason && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3 text-sm">Reason:</span>
|
||||
<span className="text-sm">{classification.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExecutionTimeline = ({ entries }: { entries: GuardrailInformation[] }) => {
|
||||
if (entries.length <= 1) return null;
|
||||
|
||||
const sorted = [...entries].sort((a, b) => (a.start_time ?? 0) - (b.start_time ?? 0));
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h5 className="font-medium mb-3">Execution Timeline</h5>
|
||||
<div className="relative pl-4 border-l-2 border-gray-200 space-y-3">
|
||||
{sorted.map((e, idx) => {
|
||||
const isSuccess = (e.guardrail_status ?? "").toLowerCase() === "success";
|
||||
return (
|
||||
<div key={idx} className="relative">
|
||||
<div
|
||||
className={`absolute -left-[calc(1rem+5px)] w-2.5 h-2.5 rounded-full mt-1.5 ${
|
||||
isSuccess ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-xs text-gray-500">
|
||||
{e.duration?.toFixed(3)}s
|
||||
</span>
|
||||
<span className="text-sm font-medium">{e.guardrail_name}</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">
|
||||
{e.guardrail_mode}
|
||||
</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
isSuccess ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}>
|
||||
{e.guardrail_status}
|
||||
</span>
|
||||
{e.policy_template && (
|
||||
<span className="px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">
|
||||
{e.policy_template}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Request Lifecycle
|
||||
</h4>
|
||||
<div className="relative">
|
||||
{timeline.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 relative">
|
||||
{/* Vertical line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{item.type === "request" || item.type === "response" ? (
|
||||
<GrayDotIcon />
|
||||
) : item.type === "llm" ? (
|
||||
<PlayCircleIcon />
|
||||
) : item.isSuccess ? (
|
||||
<CheckCircleIcon />
|
||||
) : (
|
||||
<FailCircleIcon />
|
||||
)}
|
||||
</div>
|
||||
{idx < timeline.length - 1 && (
|
||||
<div className="w-0.5 bg-gray-200 flex-grow" style={{ minHeight: "24px" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Content */}
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
item.type === "llm" ? "text-blue-600 font-medium" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.status && (
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${
|
||||
item.isSuccess
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 font-mono ml-auto flex-shrink-0">
|
||||
T+{item.offsetMs}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => {
|
||||
const guardrailProvider = entry.guardrail_provider ?? "presidio";
|
||||
const statusLabel = entry.guardrail_status ?? "unknown";
|
||||
const isSuccess = statusLabel.toLowerCase() === "success";
|
||||
const maskedEntityCount = entry.masked_entity_count || {};
|
||||
const totalMaskedEntities = Object.values(maskedEntityCount).reduce(
|
||||
(sum, count) => sum + (typeof count === "number" ? count : 0),
|
||||
0,
|
||||
);
|
||||
// ── Evaluation Card ─────────────────────────────────────────────────────────
|
||||
|
||||
const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const success = isEntrySuccess(entry);
|
||||
const totalMasked = getTotalMasked(entry);
|
||||
const displayName = getDisplayName(entry);
|
||||
const durationStr = formatDurationMs(entry.duration);
|
||||
const modeStr = formatMode(entry.guardrail_mode);
|
||||
|
||||
const guardrailProvider = entry.guardrail_provider ?? "presidio";
|
||||
const guardrailResponse = entry.guardrail_response;
|
||||
const presidioEntities = Array.isArray(guardrailResponse) ? guardrailResponse : [];
|
||||
const bedrockResponse =
|
||||
|
|
@ -297,201 +404,279 @@ const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => {
|
|||
? (guardrailResponse as BedrockGuardrailResponse)
|
||||
: undefined;
|
||||
|
||||
// Match count string: "X/Y matched" or "X matched"
|
||||
const matchCountStr =
|
||||
entry.patterns_checked != null
|
||||
? `${totalMasked}/${entry.patterns_checked} matched`
|
||||
: totalMasked > 0
|
||||
? `${totalMasked} matched`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
{total > 1 && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold">
|
||||
Guardrail #{index + 1}
|
||||
<span className="ml-2 font-mono text-sm text-gray-600">{entry.guardrail_name}</span>
|
||||
</h4>
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-md text-xs capitalize">
|
||||
{guardrailProvider}
|
||||
<div className="border border-gray-200 rounded-lg bg-white">
|
||||
{/* Collapsed header row */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{success ? <CheckCircleIcon /> : <FailCircleIcon />}
|
||||
</div>
|
||||
|
||||
{/* Name + badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||||
<span className="font-semibold text-gray-900 text-sm truncate">{displayName}</span>
|
||||
|
||||
<span className="px-2 py-0.5 border border-blue-200 bg-blue-50 text-blue-700 rounded text-[11px] font-semibold uppercase flex-shrink-0">
|
||||
{modeStr}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Guardrail Name:</span>
|
||||
<span className="font-mono break-words">{entry.guardrail_name}</span>
|
||||
</div>
|
||||
{entry.guardrail_id && entry.guardrail_id !== entry.guardrail_name && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Guardrail ID:</span>
|
||||
<span className="font-mono break-words text-gray-600">{entry.guardrail_id}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-semibold uppercase flex-shrink-0 ${
|
||||
success ? "bg-green-100 text-green-700 border border-green-200" : "bg-red-100 text-red-700 border border-red-200"
|
||||
}`}
|
||||
>
|
||||
{success ? "PASSED" : "FAILED"}
|
||||
</span>
|
||||
|
||||
{matchCountStr && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium flex-shrink-0 ${
|
||||
totalMasked === 0 ? "bg-green-50 text-green-700 border border-green-200" : "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{matchCountStr}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{entry.confidence_score != null && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 border border-gray-200 rounded text-[11px] font-medium flex-shrink-0">
|
||||
{(entry.confidence_score * 100).toFixed(0)}% conf
|
||||
</span>
|
||||
)}
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Mode:</span>
|
||||
<span className="font-mono break-words">{entry.guardrail_mode}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Status:</span>
|
||||
<Tooltip title={isSuccess ? null : "Guardrail failed to run."} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
isSuccess ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(entry.start_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(entry.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{entry.duration.toFixed(4)}s</span>
|
||||
</div>
|
||||
{/* Right side: duration + method + chevron */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className="text-sm text-gray-500 font-mono">{durationStr}</span>
|
||||
{entry.detection_method && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 border border-gray-200 rounded text-[11px] font-medium">
|
||||
{entry.detection_method.split(",")[0].trim()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronIcon expanded={expanded} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy, detection method, confidence, patterns checked */}
|
||||
<PolicyDetectionRow entry={entry} />
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 px-4 py-3">
|
||||
{/* View Policy Configuration link */}
|
||||
{entry.policy_template && (
|
||||
<div className="flex justify-end mb-3">
|
||||
<a
|
||||
href="/ui/policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
View Policy Configuration
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification details (LLM-judge) */}
|
||||
{entry.classification && <ClassificationDetails classification={entry.classification} />}
|
||||
{/* Classification details for llm-judge */}
|
||||
{entry.classification && (
|
||||
<div className="mb-3 bg-gray-50 rounded-lg p-3 space-y-1">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Classification</h5>
|
||||
{entry.classification.category && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Category:</span>
|
||||
<span>{entry.classification.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.article_reference && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Reference:</span>
|
||||
<span className="font-mono">{entry.classification.article_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.confidence != null && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Confidence:</span>
|
||||
<span>{(entry.classification.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.reason && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Reason:</span>
|
||||
<span>{entry.classification.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match details table */}
|
||||
{entry.match_details && entry.match_details.length > 0 && (
|
||||
<MatchDetailsTable matchDetails={entry.match_details} />
|
||||
)}
|
||||
{/* Match details table */}
|
||||
{entry.match_details && entry.match_details.length > 0 && (
|
||||
<MatchDetailsTable matchDetails={entry.match_details} />
|
||||
)}
|
||||
|
||||
{totalMaskedEntities > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h5 className="font-medium mb-2">Masked Entity Summary</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(maskedEntityCount).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{entityType}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Masked entity summary */}
|
||||
{totalMasked > 0 && (
|
||||
<div className="mt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Masked Entities</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(entry.masked_entity_count || {}).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entityType}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider-specific details */}
|
||||
{guardrailProvider === "presidio" && presidioEntities.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<PresidioDetectedEntities entities={presidioEntities} />
|
||||
</div>
|
||||
)}
|
||||
{guardrailProvider === "bedrock" && bedrockResponse && (
|
||||
<div className="mt-3">
|
||||
<BedrockGuardrailDetails response={bedrockResponse} />
|
||||
</div>
|
||||
)}
|
||||
{guardrailProvider === "litellm_content_filter" && guardrailResponse && (
|
||||
<div className="mt-3">
|
||||
<ContentFilterDetails response={guardrailResponse} />
|
||||
</div>
|
||||
)}
|
||||
{guardrailProvider &&
|
||||
!PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) &&
|
||||
guardrailResponse && <GenericGuardrailResponse response={guardrailResponse} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guardrailProvider === "presidio" && presidioEntities.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<PresidioDetectedEntities entities={presidioEntities} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guardrailProvider === "bedrock" && bedrockResponse && (
|
||||
<div className="mt-4">
|
||||
<BedrockGuardrailDetails response={bedrockResponse} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guardrailProvider === "litellm_content_filter" && guardrailResponse && (
|
||||
<div className="mt-4">
|
||||
<ContentFilterDetails response={guardrailResponse} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generic fallback for unknown guardrail providers */}
|
||||
{guardrailProvider &&
|
||||
!PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) &&
|
||||
guardrailResponse && <GenericGuardrailResponse response={guardrailResponse} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
||||
const guardrailEntries = Array.isArray(data)
|
||||
? data.filter((entry): entry is GuardrailInformation => Boolean(entry))
|
||||
: data
|
||||
? [data]
|
||||
: [];
|
||||
const guardrailEntries = useMemo(() => {
|
||||
return Array.isArray(data)
|
||||
? data.filter((entry): entry is GuardrailInformation => Boolean(entry))
|
||||
: data
|
||||
? [data]
|
||||
: [];
|
||||
}, [data]);
|
||||
|
||||
const primaryName =
|
||||
guardrailEntries.length === 1 ? guardrailEntries[0].guardrail_name : `${guardrailEntries.length} guardrails`;
|
||||
const statuses = Array.from(new Set(guardrailEntries.map((entry) => entry.guardrail_status)));
|
||||
const allSucceeded = statuses.every((status) => (status ?? "").toLowerCase() === "success");
|
||||
const aggregatedStatus = allSucceeded ? "success" : "failure";
|
||||
const totalMaskedEntities = guardrailEntries.reduce((sum, entry) => {
|
||||
return (
|
||||
sum +
|
||||
Object.values(entry.masked_entity_count || {}).reduce(
|
||||
(acc, count) => acc + (typeof count === "number" ? count : 0),
|
||||
0,
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
const passedCount = guardrailEntries.filter(isEntrySuccess).length;
|
||||
const allPassed = passedCount === guardrailEntries.length;
|
||||
|
||||
const policyTemplates = Array.from(
|
||||
new Set(guardrailEntries.map((e) => e.policy_template).filter(Boolean))
|
||||
);
|
||||
const totalOverheadMs = useMemo(() => {
|
||||
return Math.round(guardrailEntries.reduce((sum, e) => sum + (e.duration ?? 0), 0) * 1000);
|
||||
}, [guardrailEntries]);
|
||||
|
||||
const tooltipTitle = allSucceeded ? null : "Guardrail failed to run.";
|
||||
const policyTemplates = useMemo(() => {
|
||||
return Array.from(new Set(guardrailEntries.map((e) => e.policy_template).filter(Boolean)));
|
||||
}, [guardrailEntries]);
|
||||
|
||||
if (guardrailEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(guardrailEntries, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `guardrail-compliance-log-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-medium text-gray-900">Guardrail Information</h3>
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<ShieldIcon />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Guardrails & Policy Compliance
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-sm text-gray-500">
|
||||
{guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""} evaluated
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
allPassed
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}
|
||||
>
|
||||
{allPassed ? (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 6l2.5 2.5L9 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : null}
|
||||
{passedCount} Passed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{aggregatedStatus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<span className="font-mono text-sm text-gray-600">{primaryName}</span>
|
||||
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{policyTemplates.map((pt) => (
|
||||
<span key={pt} className="px-2 py-1 bg-purple-50 text-purple-700 rounded-md text-xs font-medium">
|
||||
{pt}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Total: {totalOverheadMs}ms overhead
|
||||
</div>
|
||||
{policyTemplates.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
Policy: {policyTemplates.join(" / ")}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-4 space-y-6">
|
||||
<ExecutionTimeline entries={guardrailEntries} />
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<GuardrailDetails
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={guardrailEntries.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Export Compliance Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body: two columns ──────────────────────────────────── */}
|
||||
<div className="flex">
|
||||
{/* Left column: Request Lifecycle */}
|
||||
<div className="w-[340px] flex-shrink-0 border-r border-gray-100 px-6 py-5">
|
||||
<RequestLifecycle entries={guardrailEntries} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Evaluation Details */}
|
||||
<div className="flex-1 px-6 py-5 min-w-0">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Evaluation Details
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<EvaluationCard
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue