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:
Ishaan Jaffer 2026-02-16 17:10:15 -08:00
parent d648e0fa4a
commit 07bab6dc34

View file

@ -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 &amp; 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>
);
};