From 07bab6dc3496e5a72ee90a86cd0f55870d02d0bd Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Mon, 16 Feb 2026 17:10:15 -0800 Subject: [PATCH] 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. --- .../GuardrailViewer/GuardrailViewer.tsx | 897 +++++++++++------- 1 file changed, 541 insertions(+), 356 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx index 8f9869e4dd5..1c8b7c7a20a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -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 = () => ( + + + + + +); + +const CheckCircleIcon = ({ className }: { className?: string }) => ( + + + + +); + +const FailCircleIcon = ({ className }: { className?: string }) => ( + + + + +); + +const PlayCircleIcon = () => ( + + + + +); + +const GrayDotIcon = () => ( + + + +); + +const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +const DownloadIcon = () => ( + + + +); + +const ExternalLinkIcon = () => ( + + + +); + +// ── Sub-components ────────────────────────────────────────────────────────── + +const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => { + if (!matchDetails || matchDetails.length === 0) return null; + + return ( +
+
Match Details ({matchDetails.length})
+
+ + + + + + + + + + + {matchDetails.map((match, idx) => ( + + + + + + + ))} + +
TypeMethodActionDetail
{match.type} + + {match.detection_method ?? "-"} + + + + {match.action_taken ?? "-"} + + + {match.category ? `[${match.category}] ` : ""} + {match.snippet ?? "-"} +
+
+
+ ); +}; const GenericGuardrailResponse = ({ response }: { response: any }) => { const [showRaw, setShowRaw] = useState(false); return ( -
+
setShowRaw(!showRaw)} >
- - - -
Raw Guardrail Response
+ +
Raw Guardrail Response
{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 ( -
-
- {entry.policy_template && ( -
- Policy: - - {entry.policy_template} - -
- )} - {entry.detection_method && ( -
- Detection: - {entry.detection_method.split(",").map((method) => ( - - {method.trim()} - - ))} -
- )} - {entry.confidence_score != null && ( -
- Confidence: - = 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)}% - -
- )} - {entry.patterns_checked != null && ( -
- Patterns checked: - {entry.patterns_checked} -
- )} -
-
+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 ( -
-
Match Details ({matchDetails.length})
-
- - - - - - - - - - - {matchDetails.map((match, idx) => ( - - - - - - - ))} - -
TypeMethodActionDetail
{match.type} - - {match.detection_method ?? "-"} - - - - {match.action_taken ?? "-"} - - - {match.category ? `[${match.category}] ` : ""}{match.snippet ?? "-"} -
-
-
- ); -}; - -const ClassificationDetails = ({ classification }: { classification: Record }) => { - if (!classification) return null; - - return ( -
-
Classification
-
- {classification.category && ( -
- Category: - {classification.category} -
- )} - {classification.article_reference && ( -
- Reference: - {classification.article_reference} -
- )} - {classification.confidence != null && ( -
- Confidence: - {(classification.confidence * 100).toFixed(0)}% -
- )} - {classification.reason && ( -
- Reason: - {classification.reason} -
- )} -
-
- ); -}; - -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 ( -
-
Execution Timeline
-
- {sorted.map((e, idx) => { - const isSuccess = (e.guardrail_status ?? "").toLowerCase() === "success"; - return ( -
-
-
- - {e.duration?.toFixed(3)}s - - {e.guardrail_name} - - {e.guardrail_mode} - - - {e.guardrail_status} - - {e.policy_template && ( - - {e.policy_template} - +
+

+ Request Lifecycle +

+
+ {timeline.map((item, idx) => ( +
+ {/* Vertical line */} +
+
+ {item.type === "request" || item.type === "response" ? ( + + ) : item.type === "llm" ? ( + + ) : item.isSuccess ? ( + + ) : ( + )}
+ {idx < timeline.length - 1 && ( +
+ )}
- ); - })} + + {/* Content */} +
+
+ + {item.label} + + {item.status && ( + + {item.status} + + )} + + T+{item.offsetMs}ms + +
+
+
+ ))}
); }; -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 ( -
- {total > 1 && ( -
-

- Guardrail #{index + 1} - {entry.guardrail_name} -

- - {guardrailProvider} +
+ {/* Collapsed header row */} +
setExpanded(!expanded)} + > + {/* Status icon */} +
+ {success ? : } +
+ + {/* Name + badges */} +
+ {displayName} + + + {modeStr} -
- )} -
-
-
- Guardrail Name: - {entry.guardrail_name} -
- {entry.guardrail_id && entry.guardrail_id !== entry.guardrail_name && ( -
- Guardrail ID: - {entry.guardrail_id} -
+ + {success ? "PASSED" : "FAILED"} + + + {matchCountStr && ( + + {matchCountStr} + + )} + + {entry.confidence_score != null && ( + + {(entry.confidence_score * 100).toFixed(0)}% conf + )} -
- Mode: - {entry.guardrail_mode} -
-
- Status: - - - {statusLabel} - - -
-
-
- Start Time: - {formatTime(entry.start_time)} -
-
- End Time: - {formatTime(entry.end_time)} -
-
- Duration: - {entry.duration.toFixed(4)}s -
+ {/* Right side: duration + method + chevron */} +
+ {durationStr} + {entry.detection_method && ( + + {entry.detection_method.split(",")[0].trim()} + + )} +
- {/* Policy, detection method, confidence, patterns checked */} - + {/* Expanded details */} + {expanded && ( +
+ {/* View Policy Configuration link */} + {entry.policy_template && ( + + )} - {/* Classification details (LLM-judge) */} - {entry.classification && } + {/* Classification details for llm-judge */} + {entry.classification && ( +
+
Classification
+ {entry.classification.category && ( +
+ Category: + {entry.classification.category} +
+ )} + {entry.classification.article_reference && ( +
+ Reference: + {entry.classification.article_reference} +
+ )} + {entry.classification.confidence != null && ( +
+ Confidence: + {(entry.classification.confidence * 100).toFixed(0)}% +
+ )} + {entry.classification.reason && ( +
+ Reason: + {entry.classification.reason} +
+ )} +
+ )} - {/* Match details table */} - {entry.match_details && entry.match_details.length > 0 && ( - - )} + {/* Match details table */} + {entry.match_details && entry.match_details.length > 0 && ( + + )} - {totalMaskedEntities > 0 && ( -
-
Masked Entity Summary
-
- {Object.entries(maskedEntityCount).map(([entityType, count]) => ( - - {entityType}: {count} - - ))} -
+ {/* Masked entity summary */} + {totalMasked > 0 && ( +
+
Masked Entities
+
+ {Object.entries(entry.masked_entity_count || {}).map(([entityType, count]) => ( + + {entityType}: {count} + + ))} +
+
+ )} + + {/* Provider-specific details */} + {guardrailProvider === "presidio" && presidioEntities.length > 0 && ( +
+ +
+ )} + {guardrailProvider === "bedrock" && bedrockResponse && ( +
+ +
+ )} + {guardrailProvider === "litellm_content_filter" && guardrailResponse && ( +
+ +
+ )} + {guardrailProvider && + !PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) && + guardrailResponse && }
)} - - {guardrailProvider === "presidio" && presidioEntities.length > 0 && ( -
- -
- )} - - {guardrailProvider === "bedrock" && bedrockResponse && ( -
- -
- )} - - {guardrailProvider === "litellm_content_filter" && guardrailResponse && ( -
- -
- )} - - {/* Generic fallback for unknown guardrail providers */} - {guardrailProvider && - !PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) && - guardrailResponse && }
); }; +// ── 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 ( -
- -

Guardrail Information

+
+ {/* ── Header ─────────────────────────────────────────────── */} +
+
+ +
+

+ Guardrails & Policy Compliance +

+
+ + {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""} evaluated + + | + + {allPassed ? ( + + + + ) : null} + {passedCount} Passed + +
+
+
- - - {aggregatedStatus} - - - - {primaryName} - - {totalMaskedEntities > 0 && ( - - {totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"} - - )} - - {policyTemplates.map((pt) => ( - - {pt} - - ))} +
+
+
+ Total: {totalOverheadMs}ms overhead +
+ {policyTemplates.length > 0 && ( +
+ Policy: {policyTemplates.join(" / ")}
- ), - children: ( -
- - {guardrailEntries.map((entry, index) => ( - - ))} -
- ), - }, - ]} - /> + )} +
+ + +
+
+ + {/* ── Body: two columns ──────────────────────────────────── */} +
+ {/* Left column: Request Lifecycle */} +
+ +
+ + {/* Right column: Evaluation Details */} +
+

+ Evaluation Details +

+
+ {guardrailEntries.map((entry, index) => ( + + ))} +
+
+
); };