Access Groups
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx
index 4253d138371..95073ad1dbd 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx
@@ -185,7 +185,6 @@ const MCPServers: React.FC
= ({ accessToken, userRole, userID })
alias: "",
url: "",
transport: "",
- spec_version: "",
auth_type: "",
created_at: "",
created_by: "",
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
index d9a8e269fa9..5213c2610eb 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
@@ -132,7 +132,6 @@ export interface MCPServer {
description?: string | null;
url: string;
transport?: string | null;
- spec_version?: string | null;
auth_type?: string | null;
mcp_info?: MCPInfo | null;
created_at: string;
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer.tsx
deleted file mode 100644
index a2909e28d4d..00000000000
--- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import React, { useState } from "react";
-
-interface RecognitionMetadata {
- recognizer_name: string;
- recognizer_identifier: string;
-}
-
-interface GuardrailEntity {
- end: number;
- score: number;
- start: number;
- entity_type: string;
- analysis_explanation: string | null;
- recognition_metadata: RecognitionMetadata;
-}
-
-interface MaskedEntityCount {
- [key: string]: number;
-}
-
-interface GuardrailInformation {
- duration: number;
- end_time: number;
- start_time: number;
- guardrail_mode: string;
- guardrail_name: string;
- guardrail_status: string;
- guardrail_response: GuardrailEntity[];
- masked_entity_count: MaskedEntityCount;
-}
-
-interface GuardrailViewerProps {
- data: GuardrailInformation;
-}
-
-export function GuardrailViewer({ data }: GuardrailViewerProps) {
- const [sectionExpanded, setSectionExpanded] = useState(true);
- const [entityListExpanded, setEntityListExpanded] = useState(true);
- const [expandedEntities, setExpandedEntities] = useState>({});
-
- if (!data) {
- return null;
- }
-
- // Calculate total masked entities
- const totalMaskedEntities = data.masked_entity_count ?
- Object.values(data.masked_entity_count).reduce((sum, count) => sum + count, 0) : 0;
-
- const formatTime = (timestamp: number): string => {
- const date = new Date(timestamp * 1000);
- return date.toLocaleString();
- };
-
- const toggleEntity = (index: number) => {
- setExpandedEntities(prev => ({
- ...prev,
- [index]: !prev[index]
- }));
- };
-
- const getScoreColor = (score: number): string => {
- if (score >= 0.8) return "text-green-600";
- return "text-yellow-600";
- };
-
- return (
-
-
setSectionExpanded(!sectionExpanded)}
- >
-
-
-
Guardrail Information
-
- {data.guardrail_status}
-
- {totalMaskedEntities > 0 && (
-
- {totalMaskedEntities} masked {totalMaskedEntities === 1 ? 'entity' : 'entities'}
-
- )}
-
-
{sectionExpanded ? 'Click to collapse' : 'Click to expand'}
-
-
- {sectionExpanded && (
-
-
-
-
-
- Guardrail Name:
- {data.guardrail_name}
-
-
- Mode:
- {data.guardrail_mode}
-
-
- Status:
-
- {data.guardrail_status}
-
-
-
-
-
- Start Time:
- {formatTime(data.start_time)}
-
-
- End Time:
- {formatTime(data.end_time)}
-
-
- Duration:
- {data.duration.toFixed(4)}s
-
-
-
-
- {/* Masked Entity Summary */}
- {data.masked_entity_count && Object.keys(data.masked_entity_count).length > 0 && (
-
-
Masked Entity Summary
-
- {Object.entries(data.masked_entity_count).map(([entityType, count]) => (
-
- {entityType}: {count}
-
- ))}
-
-
- )}
-
-
- {/* Detected Entities Section */}
- {data.guardrail_response && data.guardrail_response.length > 0 && (
-
-
setEntityListExpanded(!entityListExpanded)}
- >
-
-
Detected Entities ({data.guardrail_response.length})
-
-
- {entityListExpanded && (
-
- {data.guardrail_response.map((entity, index) => {
- const isExpanded = expandedEntities[index] || false;
-
- return (
-
-
toggleEntity(index)}
- >
-
-
-
{entity.entity_type}
-
- Score: {entity.score.toFixed(2)}
-
-
-
- Position: {entity.start}-{entity.end}
-
-
-
- {isExpanded && (
-
-
-
-
- Entity Type:
- {entity.entity_type}
-
-
- Position:
- Characters {entity.start}-{entity.end}
-
-
- Confidence:
-
- {entity.score.toFixed(2)}
-
-
-
-
-
- {entity.recognition_metadata && (
- <>
-
- Recognizer:
- {entity.recognition_metadata.recognizer_name}
-
-
- Identifier:
-
- {entity.recognition_metadata.recognizer_identifier}
-
-
- >
- )}
- {entity.analysis_explanation && (
-
- Explanation:
- {entity.analysis_explanation}
-
- )}
-
-
-
- )}
-
- );
- })}
-
- )}
-
- )}
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/BedrockGuardrailDetails.test.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/BedrockGuardrailDetails.test.tsx
new file mode 100644
index 00000000000..1cc095ac0e2
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/BedrockGuardrailDetails.test.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { describe, it, expect } from 'vitest';
+import BedrockGuardrailDetails, {
+ BedrockGuardrailResponse,
+} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
+import { renderWithProviders, screen } from "../../../../tests/test-utils"
+import {
+ makeAssessment,
+ makeBedrockCoverage,
+ makeBedrockResponse,
+ makeBedrockUsage,
+} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
+
+describe('BedrockGuardrailDetails', () => {
+ it('returns null when response is falsy', () => {
+ // @ts-expect-error testing nullish handling
+ const { container } = renderWithProviders();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders top summary: action chip, reason, blocked response', () => {
+ const resp: BedrockGuardrailResponse = makeBedrockResponse({
+ action: 'GUARDRAIL_INTERVENED',
+ actionReason: 'Policy violation',
+ blockedResponse: '[blocked]',
+ });
+ renderWithProviders();
+
+ expect(screen.getByText('Action:')).toBeInTheDocument();
+ expect(screen.getByText('Policy violation')).toBeInTheDocument();
+ expect(screen.getByText('[blocked]')).toBeInTheDocument();
+ });
+
+ it('renders coverage and usage pills', () => {
+ const resp = makeBedrockResponse({
+ guardrailCoverage: makeBedrockCoverage(),
+ usage: makeBedrockUsage({ contentPolicyUnits: 7, wordPolicyUnits: 1 }),
+ });
+ renderWithProviders();
+
+ expect(screen.getByText(/text guarded 27\/100/)).toBeInTheDocument();
+ expect(screen.getByText(/images guarded 1\/3/)).toBeInTheDocument();
+ expect(screen.getByText(/contentPolicyUnits: 7/)).toBeInTheDocument();
+ expect(screen.getByText(/wordPolicyUnits: 1/)).toBeInTheDocument();
+ });
+
+ it('renders outputs when present (prefers `outputs`, falls back to `output`)', () => {
+ // Using outputs
+ let resp = makeBedrockResponse({ outputs: [{ text: 'hello' }] });
+ const { rerender } = renderWithProviders();
+ expect(screen.getByText('Outputs')).toBeInTheDocument();
+ expect(screen.getByText('hello')).toBeInTheDocument();
+
+ // Using output
+ resp = makeBedrockResponse({ outputs: undefined, output: [{ text: 'world' }] });
+ rerender();
+ expect(screen.getByText('world')).toBeInTheDocument();
+ });
+
+ it('renders assessments with all policy sections and metrics', () => {
+ const resp = makeBedrockResponse({
+ assessments: [makeAssessment()],
+ });
+ renderWithProviders();
+
+ // Assessment section present
+ expect(screen.getByText('Assessment #1')).toBeInTheDocument();
+
+ // Word policy sections
+ expect(screen.getByText('Word Policy')).toBeInTheDocument();
+ expect(screen.getByText('Custom Words')).toBeInTheDocument();
+ expect(screen.getByText('Managed Word Lists')).toBeInTheDocument();
+
+ // Contextual grounding table headers
+ expect(screen.getByText('Contextual Grounding')).toBeInTheDocument();
+ expect(screen.getAllByText('Score').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Threshold').length).toBeGreaterThan(0);
+
+ // Sensitive Info sections
+ expect(screen.getByText('Sensitive Information')).toBeInTheDocument();
+ expect(screen.getByText('PII Entities')).toBeInTheDocument();
+ expect(screen.getByText('Custom Regexes')).toBeInTheDocument();
+
+ // Topic Policy
+ expect(screen.getByText('Topic Policy')).toBeInTheDocument();
+ expect(screen.getByText('weapons')).toBeInTheDocument();
+
+ // Invocation Metrics
+ expect(screen.getByText('Invocation Metrics')).toBeInTheDocument();
+
+ // Raw JSON section exists (closed by default)
+ expect(screen.getByText('Raw Bedrock Guardrail Response')).toBeInTheDocument();
+ });
+
+ it('handles non-text outputs gracefully', () => {
+ const resp = makeBedrockResponse({ outputs: [{}, { text: 'texty' }] });
+ renderWithProviders();
+ expect(screen.getByText('(non-text output)')).toBeInTheDocument();
+ expect(screen.getByText('texty')).toBeInTheDocument();
+ });
+
+ it('gracefully handles missing optional sections', () => {
+ const resp = makeBedrockResponse({
+ assessments: [
+ {
+ // only include minimal fields; others omitted
+ invocationMetrics: { guardrailProcessingLatency: 5 },
+ } as any,
+ ],
+ usage: undefined,
+ guardrailCoverage: undefined,
+ outputs: [],
+ });
+ renderWithProviders();
+ // No crash, minimal render: Assessment + Invocation Metrics present, but no usage/coverage chips at top
+ expect(screen.getByText('Assessment #1')).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/BedrockGuardrailDetails.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/BedrockGuardrailDetails.tsx
new file mode 100644
index 00000000000..e7b041b93b4
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/BedrockGuardrailDetails.tsx
@@ -0,0 +1,508 @@
+import React, { useState } from "react";
+
+export type BedrockGuardrailAction = "NONE" | "GUARDRAIL_INTERVENED";
+
+export interface BedrockGuardrailUsage {
+ wordPolicyUnits?: number;
+ topicPolicyUnits?: number;
+ contentPolicyUnits?: number;
+ contentPolicyImageUnits?: number;
+ automatedReasoningPolicies?: number;
+ automatedReasoningPolicyUnits?: number;
+ contextualGroundingPolicyUnits?: number;
+ sensitiveInformationPolicyUnits?: number;
+ sensitiveInformationPolicyFreeUnits?: number;
+}
+
+export interface BedrockGuardrailCoverageDim {
+ total?: number;
+ guarded?: number;
+}
+
+export interface BedrockGuardrailCoverage {
+ images?: BedrockGuardrailCoverageDim;
+ textCharacters?: BedrockGuardrailCoverageDim;
+}
+
+export interface BedrockWordItem {
+ action?: string; // e.g., "BLOCKED" or "ALLOWED"
+ detected?: boolean;
+ match?: string;
+ type?: string; // present on managedWordLists entries
+}
+
+export interface BedrockContentFilter {
+ type?: string; // e.g. "HATE", etc.
+ action?: string; // "BLOCKED" | "NONE" (varies by config)
+ detected?: boolean;
+ filterStrength?: string; // e.g., "HIGH"/"MEDIUM"/"LOW"
+ confidence?: string; // e.g., "HIGH"/"MEDIUM"/"LOW"
+}
+
+export interface BedrockContextualGroundingFilter {
+ type?: string; // "GROUNDING" | "RELEVANCE"
+ action?: string; // "BLOCKED" | ...
+ detected?: boolean;
+ score?: number; // model score
+ threshold?: number; // configured threshold
+}
+
+export interface BedrockPiiEntity {
+ type?: string; // e.g., "ADDRESS", "EMAIL", etc.
+ match?: string; // the matched text (may be masked)
+ detected?: boolean;
+ action?: string; // "BLOCKED" | "ANONYMIZED" | ...
+}
+
+export interface BedrockRegexFinding {
+ name?: string;
+ regex?: string;
+ match?: string;
+ detected?: boolean;
+ action?: string;
+}
+
+export interface BedrockTopic {
+ name?: string; // topic list entry name
+ type?: string; // "DENY" | "ALLOW" depending on list
+ detected?: boolean;
+ action?: string; // "BLOCKED" | "NONE"
+}
+
+export interface BedrockInvocationMetrics {
+ usage?: BedrockGuardrailUsage;
+ guardrailCoverage?: BedrockGuardrailCoverage;
+ guardrailProcessingLatency?: number; // in ms
+}
+
+export interface BedrockAssessment {
+ wordPolicy?: { customWords?: BedrockWordItem[]; managedWordLists?: BedrockWordItem[] };
+ contentPolicy?: { filters?: BedrockContentFilter[] };
+ topicPolicy?: { topics?: BedrockTopic[] };
+ sensitiveInformationPolicy?: { piiEntities?: BedrockPiiEntity[]; regexes?: BedrockRegexFinding[] };
+ contextualGroundingPolicy?: { filters?: BedrockContextualGroundingFilter[] };
+ automatedReasoningPolicy?: { findings?: any[] };
+ invocationMetrics?: BedrockInvocationMetrics;
+}
+
+export interface BedrockOutputContent {
+ text?: string;
+}
+
+export interface BedrockGuardrailResponse {
+ action?: BedrockGuardrailAction;
+ actionReason?: string | null;
+ outputs?: BedrockOutputContent[];
+ output?: BedrockOutputContent[];
+ usage?: BedrockGuardrailUsage;
+ guardrailCoverage?: BedrockGuardrailCoverage;
+ assessments?: BedrockAssessment[];
+ blockedResponse?: string;
+}
+
+/** ====== UI helpers ====== */
+type ChipTone = "green" | "red" | "blue" | "slate" | "amber";
+
+const chip = (
+ text: React.ReactNode,
+ tone: ChipTone = "slate"
+) => {
+ const map: Record = {
+ green: "bg-green-100 text-green-800",
+ red: "bg-red-100 text-red-800",
+ blue: "bg-blue-50 text-blue-700",
+ slate: "bg-slate-100 text-slate-800",
+ amber: "bg-amber-100 text-amber-800",
+ };
+ return {text};
+};
+
+const boolPill = (b?: boolean) => (b ? chip("detected", "red") : chip("not detected", "slate"));
+
+interface SectionProps {
+ title: string;
+ count?: number;
+ defaultOpen?: boolean;
+ right?: React.ReactNode;
+ children?: React.ReactNode;
+}
+
+const Section: React.FC = ({
+ title,
+ count,
+ defaultOpen = true,
+ right,
+ children,
+ }) => {
+ const [open, setOpen] = useState(defaultOpen);
+ return (
+
+
setOpen((v) => !v)}
+ >
+
+
+
+ {title} {typeof count === "number" && ({count})}
+
+
+
{right}
+
+ {open &&
{children}
}
+
+ );
+};
+
+interface KVProps {
+ label: string;
+ children?: React.ReactNode;
+ mono?: boolean;
+}
+
+const KV: React.FC = ({ label, children, mono }) => (
+
+ {label}
+ {children}
+
+);
+
+const Divider: React.FC = () => ;
+
+/** ====== Main component ====== */
+export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailResponse }> = ({ response }) => {
+
+ if (!response) return null;
+
+ const outputs: BedrockOutputContent[] = (response.outputs ?? response.output ?? []) as BedrockOutputContent[];
+
+ const actionTone: "green" | "red" = response.action === "GUARDRAIL_INTERVENED" ? "red" : "green";
+
+ const coverageChips = (
+
+ {response.guardrailCoverage?.textCharacters && (
+ chip(
+ `text guarded ${response.guardrailCoverage.textCharacters.guarded ?? 0}/${response.guardrailCoverage.textCharacters.total ?? 0}`,
+ "blue"
+ )
+ )}
+ {response.guardrailCoverage?.images && (
+ chip(
+ `images guarded ${response.guardrailCoverage.images.guarded ?? 0}/${response.guardrailCoverage.images.total ?? 0}`,
+ "blue"
+ )
+ )}
+
+ );
+
+ const usagePills = response.usage && (
+
+ {Object.entries(response.usage).map(([k, v]) =>
+ typeof v === "number" ? (
+
+ {k}: {v}
+
+ ) : null
+ )}
+
+ );
+
+ return (
+
+ {/* Top summary card */}
+
+
+
+
+ {chip(response.action ?? "N/A", actionTone)}
+
+ {response.actionReason && {response.actionReason}}
+ {response.blockedResponse && (
+
+ {response.blockedResponse}
+
+ )}
+
+
+ {coverageChips}
+ {usagePills}
+
+
+
+ {/* Outputs */}
+ {outputs.length > 0 && (
+ <>
+
+
Outputs
+
+ {outputs.map((o, i) => (
+
+
{o.text ?? (non-text output)}
+
+ ))}
+
+ >
+ )}
+
+
+ {/* Assessments */}
+ {response.assessments?.length ? (
+
+ {response.assessments.map((assess, idx) => {
+ const policyBadges = (
+
+ {assess.wordPolicy && chip("word", "slate")}
+ {assess.contentPolicy && chip("content", "slate")}
+ {assess.topicPolicy && chip("topic", "slate")}
+ {assess.sensitiveInformationPolicy && chip("sensitive-info", "slate")}
+ {assess.contextualGroundingPolicy && chip("contextual-grounding", "slate")}
+ {assess.automatedReasoningPolicy && chip("automated-reasoning", "slate")}
+
+ );
+
+ return (
+
+ {assess.invocationMetrics?.guardrailProcessingLatency != null &&
+ chip(`${assess.invocationMetrics.guardrailProcessingLatency} ms`, "amber")}
+ {policyBadges}
+
+ }
+ >
+ {/* Word policy */}
+ {assess.wordPolicy && (
+
+
Word Policy
+ {(assess.wordPolicy.customWords?.length ?? 0) > 0 && (
+
+
+ {assess.wordPolicy.customWords!.map((w, i) => (
+
+
+ {chip(w.action ?? "N/A", w.detected ? "red" : "slate")}
+ {w.match}
+
+ {boolPill(w.detected)}
+
+ ))}
+
+
+ )}
+ {(assess.wordPolicy.managedWordLists?.length ?? 0) > 0 && (
+
+
+ {assess.wordPolicy.managedWordLists!.map((w, i) => (
+
+
+ {chip(w.action ?? "N/A", w.detected ? "red" : "slate")}
+ {w.match}
+ {w.type && chip(w.type, "slate")}
+
+ {boolPill(w.detected)}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Content policy */}
+ {assess.contentPolicy?.filters?.length ? (
+
+
Content Policy
+
+
+
+
+ | Type |
+ Action |
+ Detected |
+ Strength |
+ Confidence |
+
+
+
+ {assess.contentPolicy.filters!.map((f, i) => (
+
+ | {f.type ?? "—"} |
+ {chip(f.action ?? "—", f.detected ? "red" : "slate")} |
+ {boolPill(f.detected)} |
+ {f.filterStrength ?? "—"} |
+ {f.confidence ?? "—"} |
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {/* Contextual grounding */}
+ {assess.contextualGroundingPolicy?.filters?.length ? (
+
+
Contextual Grounding
+
+
+
+
+ | Type |
+ Action |
+ Detected |
+ Score |
+ Threshold |
+
+
+
+ {assess.contextualGroundingPolicy.filters!.map((f, i) => (
+
+ | {f.type ?? "—"} |
+ {chip(f.action ?? "—", f.detected ? "red" : "slate")} |
+ {boolPill(f.detected)} |
+ {f.score ?? "—"} |
+ {f.threshold ?? "—"} |
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {/* Sensitive Information */}
+ {assess.sensitiveInformationPolicy && (
+
+
Sensitive Information
+ {(assess.sensitiveInformationPolicy.piiEntities?.length ?? 0) > 0 && (
+
+
+ {assess.sensitiveInformationPolicy.piiEntities!.map((p, i) => (
+
+
+ {chip(p.action ?? "N/A", p.detected ? "red" : "slate")}
+ {p.type && chip(p.type, "slate")}
+ {p.match}
+
+ {boolPill(p.detected)}
+
+ ))}
+
+
+ )}
+ {(assess.sensitiveInformationPolicy.regexes?.length ?? 0) > 0 && (
+
+
+ {assess.sensitiveInformationPolicy.regexes!.map((r, i) => (
+
+
+ {chip(r.action ?? "N/A", r.detected ? "red" : "slate")}
+ {r.name ?? "regex"}
+ {r.regex}
+
+
+ {boolPill(r.detected)}
+ {r.match && {r.match}}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Topic policy */}
+ {assess.topicPolicy?.topics?.length ? (
+
+
Topic Policy
+
+ {assess.topicPolicy.topics!.map((t, i) => (
+
+
+ {chip(t.action ?? "N/A", t.detected ? "red" : "slate")}
+ {t.name ?? "topic"}
+ {t.type && chip(t.type, "slate")}
+ {boolPill(t.detected)}
+
+
+ ))}
+
+
+ ) : null}
+
+ {/* Invocation metrics */}
+ {assess.invocationMetrics && (
+
+
+
+
{assess.invocationMetrics.guardrailProcessingLatency ?? "—"}
+
+
+ {assess.invocationMetrics.guardrailCoverage?.textCharacters &&
+ chip(
+ `text ${assess.invocationMetrics.guardrailCoverage.textCharacters.guarded ?? 0}/${
+ assess.invocationMetrics.guardrailCoverage.textCharacters.total ?? 0
+ }`,
+ "blue"
+ )}
+ {assess.invocationMetrics.guardrailCoverage?.images &&
+ chip(
+ `images ${assess.invocationMetrics.guardrailCoverage.images.guarded ?? 0}/${
+ assess.invocationMetrics.guardrailCoverage.images.total ?? 0
+ }`,
+ "blue"
+ )}
+
+
+
+
+
+
+ {assess.invocationMetrics.usage &&
+ Object.entries(assess.invocationMetrics.usage).map(([k, v]) =>
+ typeof v === "number" ? (
+
+ {k}: {v}
+
+ ) : null
+ )}
+
+
+
+
+
+ )}
+
+ {/* Automated reasoning (fallback render) */}
+ {assess.automatedReasoningPolicy?.findings?.length ? (
+
+
+ {assess.automatedReasoningPolicy.findings!.map((f, i) => (
+
+ {JSON.stringify(f, null, 2)}
+
+ ))}
+
+
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+
+ {/* Raw JSON (for debugging / completeness) */}
+
+ {JSON.stringify(response, null, 2)}
+
+
+ );
+};
+
+export default BedrockGuardrailDetails;
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx
new file mode 100644
index 00000000000..2e47de52e88
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx
@@ -0,0 +1,154 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { renderWithProviders, screen } from "../../../../tests/test-utils"
+import {
+ makeBedrockResponse, makeEntity,
+ makeGuardrailInformation,
+} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
+import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
+
+// We will mock child components selectively for some tests to assert prop passthrough,
+// but also run an integration-style render without mocks.
+const PresidioPath = '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
+const BedrockPath = '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
+
+describe('GuardrailViewer', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('shows header, status pill color, duration rounding, and time labels', () => {
+ const data = makeGuardrailInformation({ duration: 1.23456, guardrail_status: 'success' });
+ renderWithProviders(
);
+
+ expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
+ // header status pill (success => green)
+ const statusBadges = screen.getAllByText('success');
+ // there are two status locations: header chip and grid "Status"
+ expect(statusBadges.length).toBeGreaterThanOrEqual(1);
+ // Quick class assertion for at least one of them
+ expect(statusBadges[0].className).toMatch(/bg-green-100/);
+
+ // duration displays with 4 decimals
+ expect(screen.getByText(/1\.2346s/)).toBeInTheDocument();
+
+ // time labels exist
+ expect(screen.getByText('Start Time:')).toBeInTheDocument();
+ expect(screen.getByText('End Time:')).toBeInTheDocument();
+ });
+
+ it('calculates and displays masked entity totals with pluralization', () => {
+ const data = makeGuardrailInformation({
+ masked_entity_count: { EMAIL_ADDRESS: 2, PHONE_NUMBER: 1 },
+ });
+ renderWithProviders(
);
+
+ expect(screen.getByText('3 masked entities')).toBeInTheDocument();
+ // summary chips for each entry
+ expect(screen.getByText('EMAIL_ADDRESS: 2')).toBeInTheDocument();
+ expect(screen.getByText('PHONE_NUMBER: 1')).toBeInTheDocument();
+ });
+
+ it('hides masked badge & summary when count is zero/empty', () => {
+ const data = makeGuardrailInformation({ masked_entity_count: {} });
+ renderWithProviders(
);
+
+ expect(screen.queryByText(/masked entity/)).not.toBeInTheDocument();
+ expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
+ });
+
+ it('toggles main section open/closed and chevron rotation class', async () => {
+ const user = userEvent.setup();
+ const data = makeGuardrailInformation();
+ renderWithProviders(
);
+
+ const header = screen.getByText('Guardrail Information').closest('div')!;
+ // Initially expanded
+ expect(screen.getByText('Click to collapse')).toBeInTheDocument();
+ // Click to collapse
+ await user.click(header);
+ expect(screen.getByText('Click to expand')).toBeInTheDocument();
+ // Details gone
+ expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
+
+ // Click to expand again
+ await user.click(header);
+ expect(screen.getByText('Click to collapse')).toBeInTheDocument();
+ });
+
+ it('defaults to presidio provider when guardrail_provider is undefined', async () => {
+ vi.doMock(PresidioPath, () => ({
+ __esModule: true,
+ default: ({ entities }: any) =>
presidio {entities?.length}
,
+ }));
+ const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
+
+ const data = makeGuardrailInformation({
+ guardrail_provider: undefined,
+ guardrail_response: [makeEntity(), makeEntity()],
+ });
+ renderWithProviders(
);
+
+ expect(screen.getByTestId('presidio-mock')).toHaveTextContent('presidio 2');
+ });
+
+ it('renders PresidioDetectedEntities when provider="presidio" and response has entities', async () => {
+ vi.doMock(PresidioPath, () => ({
+ __esModule: true,
+ default: ({ entities }: any) =>
count:{entities?.length}
,
+ }));
+ const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
+
+ const data = makeGuardrailInformation({
+ guardrail_provider: 'presidio',
+ guardrail_response: [makeEntity()],
+ });
+ renderWithProviders(
);
+ expect(screen.getByTestId('presidio-mock')).toHaveTextContent('count:1');
+ });
+
+ it('renders BedrockGuardrailDetails when provider="bedrock"', async () => {
+ vi.doMock(BedrockPath, () => ({
+ __esModule: true,
+ default: ({ response }: any) => (
+
{response?.action ?? 'no-action'}
+ ),
+ }));
+ const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
+
+ const data = makeGuardrailInformation({
+ guardrail_provider: 'bedrock',
+ guardrail_response: makeBedrockResponse({ action: 'GUARDRAIL_INTERVENED' }),
+ });
+ renderWithProviders(
);
+ expect(screen.getByTestId('bedrock-mock')).toHaveTextContent('GUARDRAIL_INTERVENED');
+ });
+
+ it('unknown provider renders neither Presidio nor Bedrock details', () => {
+ const data = makeGuardrailInformation({
+ guardrail_provider: 'unknown',
+ });
+ renderWithProviders(
);
+ // Summary still present
+ expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
+ // No provider sections
+ expect(screen.queryByText(/Detected Entities/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Raw Bedrock Guardrail Response/)).not.toBeInTheDocument();
+ });
+
+ it('integration: renders with real Bedrock details without mocks', () => {
+ const data = makeGuardrailInformation({
+ guardrail_provider: 'bedrock',
+ guardrail_response: makeBedrockResponse({
+ action: 'NONE',
+ outputs: [{ text: 'ok' }],
+ }),
+ });
+ renderWithProviders(
);
+
+ // Bedrock summary bits
+ expect(screen.getByText('Outputs')).toBeInTheDocument();
+ expect(screen.getByText('ok')).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx
new file mode 100644
index 00000000000..ebacda497c8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx
@@ -0,0 +1,176 @@
+import React, { useState } from "react";
+import { Tooltip } from "antd";
+import PresidioDetectedEntities from "./PresidioDetectedEntities";
+import BedrockGuardrailDetails, {
+ BedrockGuardrailResponse,
+} from "@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails";
+
+interface RecognitionMetadata {
+ recognizer_name: string;
+ recognizer_identifier: string;
+}
+
+interface GuardrailEntity {
+ end: number;
+ score: number;
+ start: number;
+ entity_type: string;
+ analysis_explanation: string | null;
+ recognition_metadata: RecognitionMetadata;
+}
+
+interface MaskedEntityCount {
+ [key: string]: number;
+}
+
+interface GuardrailInformation {
+ duration: number;
+ end_time: number;
+ start_time: number;
+ guardrail_mode: string;
+ guardrail_name: string;
+ guardrail_status: string;
+ guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
+ masked_entity_count: MaskedEntityCount;
+ guardrail_provider?: string; // "presidio" | other providers
+}
+
+interface GuardrailViewerProps {
+ data: GuardrailInformation;
+}
+
+const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
+ const [sectionExpanded, setSectionExpanded] = useState(true);
+
+ // Default to presidio for backwards compatibility
+ const guardrailProvider = data.guardrail_provider ?? "presidio";
+
+ if (!data) return null;
+
+ const isSuccess =
+ typeof data.guardrail_status === "string" &&
+ data.guardrail_status.toLowerCase() === "success";
+
+ const tooltipTitle = isSuccess ? null : "Guardrail failed to run.";
+
+ // Calculate total masked entities
+ const totalMaskedEntities = data.masked_entity_count ?
+ Object.values(data.masked_entity_count).reduce((sum, count) => sum + count, 0) : 0;
+
+ const formatTime = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ return date.toLocaleString();
+ };
+
+ return (
+
+
setSectionExpanded(!sectionExpanded)}
+ >
+
+
+
Guardrail Information
+
+ {/* Header status chip with tooltip */}
+
+
+ {data.guardrail_status}
+
+
+
+ {totalMaskedEntities > 0 && (
+
+ {totalMaskedEntities} masked {totalMaskedEntities === 1 ? 'entity' : 'entities'}
+
+ )}
+
+
{sectionExpanded ? 'Click to collapse' : 'Click to expand'}
+
+
+ {sectionExpanded && (
+
+
+
+
+
+ Guardrail Name:
+ {data.guardrail_name}
+
+
+ Mode:
+ {data.guardrail_mode}
+
+
+ Status:
+
+
+ {data.guardrail_status}
+
+
+
+
+
+
+
+ Start Time:
+ {formatTime(data.start_time)}
+
+
+ End Time:
+ {formatTime(data.end_time)}
+
+
+ Duration:
+ {data.duration.toFixed(4)}s
+
+
+
+
+ {/* Masked Entity Summary */}
+ {data.masked_entity_count && Object.keys(data.masked_entity_count).length > 0 && (
+
+
Masked Entity Summary
+
+ {Object.entries(data.masked_entity_count).map(([entityType, count]) => (
+
+ {entityType}: {count}
+
+ ))}
+
+
+ )}
+
+
+ {/* Provider-specific Detected Entities */}
+ {guardrailProvider === "presidio" && (data.guardrail_response as GuardrailEntity[])?.length > 0 && (
+
+ )}
+
+ {guardrailProvider === "bedrock" && data.guardrail_response && (
+
+
+
+ )}
+
+ )}
+
+ );
+};
+
+export default GuardrailViewer;
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/PresidioDetectedEntities.test.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/PresidioDetectedEntities.test.tsx
new file mode 100644
index 00000000000..2360561f64e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/PresidioDetectedEntities.test.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { describe, it, expect } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import PresidioDetectedEntities from '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
+import { renderWithProviders, screen } from "../../../../tests/test-utils"
+import { makeEntity } from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
+
+describe('PresidioDetectedEntities', () => {
+ it('renders null when entities empty', () => {
+ const { container } = renderWithProviders(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders per-entity header info including score color and position', async () => {
+ const user = userEvent.setup();
+ const e = makeEntity({ start: 10, end: 20, score: 0.92, entity_type: 'EMAIL_ADDRESS' });
+ renderWithProviders(
);
+
+ // Header row values
+ expect(screen.getByText('EMAIL_ADDRESS')).toBeInTheDocument();
+ expect(screen.getByText(/Score: 0\.92/)).toBeInTheDocument();
+ expect(screen.getByText('Position: 10-20')).toBeInTheDocument();
+
+ // Expand details
+ await user.click(screen.getByText('EMAIL_ADDRESS'));
+ expect(screen.getByText('Entity Type:')).toBeInTheDocument();
+ expect(screen.getByText('Characters 10-20')).toBeInTheDocument();
+ expect(screen.getByText('Confidence:')).toBeInTheDocument();
+ // Recognizer details
+ expect(screen.getByText('EmailRecognizer')).toBeInTheDocument();
+ expect(screen.getByText('email_v1')).toBeInTheDocument();
+ // Explanation
+ expect(screen.getByText('Matched via pattern')).toBeInTheDocument();
+ });
+
+ it('handles missing metadata & low scores gracefully', async () => {
+ const user = userEvent.setup();
+ const e = makeEntity({
+ score: 0.3,
+ recognition_metadata: undefined as any,
+ analysis_explanation: null,
+ entity_type: 'NAME',
+ start: 0,
+ end: 0,
+ });
+ renderWithProviders(
);
+
+ await user.click(screen.getByText('NAME'));
+ // No recognizer/explanation rows
+ expect(screen.queryByText('Recognizer:')).not.toBeInTheDocument();
+ expect(screen.queryByText('Explanation:')).not.toBeInTheDocument();
+ // Position still renders
+ expect(screen.getByText('Characters 0-0')).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/PresidioDetectedEntities.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/PresidioDetectedEntities.tsx
new file mode 100644
index 00000000000..d03478a569e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/PresidioDetectedEntities.tsx
@@ -0,0 +1,136 @@
+import React, { useState } from "react";
+
+interface RecognitionMetadata {
+ recognizer_name: string;
+ recognizer_identifier: string;
+}
+
+export interface GuardrailEntity {
+ end: number;
+ score: number;
+ start: number;
+ entity_type: string;
+ analysis_explanation: string | null;
+ recognition_metadata: RecognitionMetadata;
+}
+
+interface PresidioDetectedEntitiesProps {
+ entities: GuardrailEntity[];
+}
+
+const getScoreColor = (score: number): string => {
+ if (score >= 0.8) return "text-green-600";
+ return "text-yellow-600";
+};
+
+const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) => {
+ const [entityListExpanded, setEntityListExpanded] = useState(true);
+ const [expandedEntities, setExpandedEntities] = useState
>({});
+
+ const toggleEntity = (index: number) => {
+ setExpandedEntities((prev) => ({
+ ...prev,
+ [index]: !prev[index],
+ }));
+ };
+
+ if (!entities || entities.length === 0) return null;
+
+ return (
+
+
setEntityListExpanded(!entityListExpanded)}
+ >
+
+
Detected Entities ({entities.length})
+
+
+ {entityListExpanded && (
+
+ {entities.map((entity, index) => {
+ const isExpanded = expandedEntities[index] || false;
+
+ return (
+
+
toggleEntity(index)}
+ >
+
+
+
{entity.entity_type}
+
+ Score: {entity.score.toFixed(2)}
+
+
+
Position: {entity.start}-{entity.end}
+
+
+ {isExpanded && (
+
+
+
+
+ Entity Type:
+ {entity.entity_type}
+
+
+ Position:
+ Characters {entity.start}-{entity.end}
+
+
+ Confidence:
+ {entity.score.toFixed(2)}
+
+
+
+
+ {entity.recognition_metadata && (
+ <>
+
+ Recognizer:
+ {entity.recognition_metadata.recognizer_name}
+
+
+ Identifier:
+
+ {entity.recognition_metadata.recognizer_identifier}
+
+
+ >
+ )}
+ {entity.analysis_explanation && (
+
+ Explanation:
+ {entity.analysis_explanation}
+
+ )}
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export default PresidioDetectedEntities;
diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/__tests__/fixtures.ts b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/__tests__/fixtures.ts
new file mode 100644
index 00000000000..4a19e403a69
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/__tests__/fixtures.ts
@@ -0,0 +1,119 @@
+import type {
+ BedrockGuardrailResponse,
+ BedrockAssessment,
+ BedrockGuardrailCoverage,
+ BedrockGuardrailUsage,
+} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
+
+export interface RecognitionMetadata {
+ recognizer_name: string;
+ recognizer_identifier: string;
+}
+
+export interface GuardrailEntity {
+ end: number;
+ score: number;
+ start: number;
+ entity_type: string;
+ analysis_explanation: string | null;
+ recognition_metadata: RecognitionMetadata;
+}
+
+export interface GuardrailInformation {
+ duration: number;
+ end_time: number;
+ start_time: number;
+ guardrail_mode: string;
+ guardrail_name: string;
+ guardrail_status: string;
+ guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
+ masked_entity_count: Record;
+ guardrail_provider?: string;
+}
+
+// ===== Builders =====
+export const makeEntity = (overrides: Partial = {}): GuardrailEntity => ({
+ end: 18,
+ start: 5,
+ score: 0.92,
+ entity_type: 'EMAIL_ADDRESS',
+ analysis_explanation: 'Matched via pattern',
+ recognition_metadata: {
+ recognizer_name: 'EmailRecognizer',
+ recognizer_identifier: 'email_v1',
+ },
+ ...overrides,
+});
+
+export const makeMaskedCounts = (overrides: Record = {}) => ({
+ EMAIL_ADDRESS: 2,
+ PHONE_NUMBER: 1,
+ ...overrides,
+});
+
+export const makeBedrockUsage = (overrides: Partial = {}): BedrockGuardrailUsage => ({
+ contentPolicyUnits: 4,
+ topicPolicyUnits: 2,
+ ...overrides,
+});
+
+export const makeBedrockCoverage = (
+ overrides: Partial = {}
+): BedrockGuardrailCoverage => ({
+ textCharacters: { guarded: 27, total: 100 },
+ images: { guarded: 1, total: 3 },
+ ...overrides,
+});
+
+export const makeAssessment = (overrides: Partial = {}): BedrockAssessment => ({
+ wordPolicy: {
+ customWords: [{ action: 'BLOCKED', detected: true, match: 'badword' }],
+ managedWordLists: [{ action: 'ALLOWED', detected: false, match: 'ok', type: 'PROFANITY' }],
+ },
+ contentPolicy: {
+ filters: [
+ { type: 'HATE', action: 'BLOCKED', detected: true, filterStrength: 'HIGH', confidence: 'MEDIUM' },
+ { type: 'VIOLENCE', action: 'NONE', detected: false, filterStrength: 'LOW', confidence: 'LOW' },
+ ],
+ },
+ topicPolicy: { topics: [{ name: 'weapons', type: 'DENY', detected: true, action: 'BLOCKED' }] },
+ sensitiveInformationPolicy: {
+ piiEntities: [{ type: 'EMAIL', match: 'x@y.com', detected: true, action: 'ANONYMIZED' }],
+ regexes: [{ name: 'ticket', regex: '#[0-9]+', match: '#123', detected: true, action: 'BLOCKED' }],
+ },
+ contextualGroundingPolicy: {
+ filters: [{ type: 'GROUNDING', action: 'BLOCKED', detected: true, score: 0.2, threshold: 0.5 }],
+ },
+ automatedReasoningPolicy: { findings: [{ foo: 'bar' }] },
+ invocationMetrics: {
+ guardrailProcessingLatency: 42,
+ usage: makeBedrockUsage(),
+ guardrailCoverage: makeBedrockCoverage(),
+ },
+ ...overrides,
+});
+
+export const makeBedrockResponse = (
+ overrides: Partial = {}
+): BedrockGuardrailResponse => ({
+ action: 'NONE',
+ outputs: [{ text: 'ok' }],
+ usage: makeBedrockUsage(),
+ guardrailCoverage: makeBedrockCoverage(),
+ assessments: [makeAssessment()],
+ ...overrides,
+});
+
+export const makeGuardrailInformation = (
+ overrides: Partial = {}
+): GuardrailInformation => ({
+ guardrail_name: 'pii-rail',
+ guardrail_mode: 'post',
+ guardrail_status: 'success',
+ start_time: 1_700_000_000,
+ end_time: 1_700_000_123,
+ duration: 0.123456,
+ guardrail_response: [makeEntity()],
+ masked_entity_count: makeMaskedCounts(),
+ ...overrides,
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx
index 4552870ae9b..2a916db7f0f 100644
--- a/ui/litellm-dashboard/src/components/view_logs/index.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx
@@ -17,7 +17,7 @@ import { KeyResponse, Team } from "../key_team_helpers/key_list"
import KeyInfoView from "../templates/key_info_view"
import { SessionView } from "./SessionView"
import { VectorStoreViewer } from "./VectorStoreViewer"
-import { GuardrailViewer } from "./GuardrailViewer"
+import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
import FilterComponent from "../molecules/filter"
import { FilterOption } from "../molecules/filter"
import { useLogFilterLogic } from "./log_filter_logic"
diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts
new file mode 100644
index 00000000000..cf8dc78b7ee
--- /dev/null
+++ b/ui/litellm-dashboard/tests/setupTests.ts
@@ -0,0 +1,15 @@
+import '@testing-library/jest-dom';
+import { afterEach, vi } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+afterEach(() => {
+ cleanup();
+});
+
+// Make toLocaleString deterministic in tests; individual tests can override
+// This returns ISO-like strings to keep assertions stable.
+vi.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function (this: Date, ..._args: unknown[]) {
+ const d = this;
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+});
diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx
new file mode 100644
index 00000000000..c81a9b5d79a
--- /dev/null
+++ b/ui/litellm-dashboard/tests/test-utils.tsx
@@ -0,0 +1,12 @@
+import React, { PropsWithChildren } from 'react';
+import { render, RenderOptions } from '@testing-library/react';
+
+const Providers: React.FC = ({ children }) => {
+ // Add future providers here (Theme/Router/QueryClient/etc.)
+ return <>{children}>;
+};
+
+export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) =>
+ render(ui, { wrapper: Providers, ...options });
+
+export * from '@testing-library/react';
diff --git a/ui/litellm-dashboard/vitest.config.ts b/ui/litellm-dashboard/vitest.config.ts
new file mode 100644
index 00000000000..d24c0d7f2a5
--- /dev/null
+++ b/ui/litellm-dashboard/vitest.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config'
+import { resolve } from "path"
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['tests/setupTests.ts'],
+ globals: true,
+ css: true, // lets you import CSS/modules without extra mocks
+ coverage: { reporter: ['text', 'lcov'] },
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+})