diff --git a/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx
new file mode 100644
index 00000000000..e12c2917a00
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx
@@ -0,0 +1,203 @@
+import React from "react";
+import { Card, Tag, Table, Typography, Space, Tooltip } from "antd";
+import { CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined } from "@ant-design/icons";
+
+const { Text } = Typography;
+
+interface EvalVerdict {
+ criterion_name: string;
+ score: number;
+ reasoning: string;
+ passed: boolean;
+ weight?: number;
+}
+
+interface EvalInformation {
+ eval_id?: string;
+ eval_name: string;
+ overall_score: number;
+ passed: boolean;
+ judge_model: string;
+ iteration?: number;
+ eval_error?: string | null;
+ verdicts?: EvalVerdict[];
+ threshold?: number;
+}
+
+interface EvalViewerProps {
+ data: EvalInformation | EvalInformation[];
+}
+
+export default function EvalViewer({ data }: EvalViewerProps) {
+ const entries: EvalInformation[] = Array.isArray(data) ? data : [data];
+
+ if (!entries.length) return null;
+
+ return (
+
+
+
+
+ LLM Judge Results
+
+
+
+ {entries.map((entry, idx) => (
+
+ ))}
+
+ );
+}
+
+function EvalEntryCard({ entry }: { entry: EvalInformation }) {
+ const passed = entry.passed;
+ const scoreColor = passed ? "#52c41a" : "#ff4d4f";
+
+ // Filter out synthetic "Overall" row the judge sometimes appends — it's already in the header
+ const verdicts = (entry.verdicts || []).filter(
+ (v) => (v.criterion_name || "").toLowerCase() !== "overall"
+ );
+
+ const columns = [
+ {
+ title: "Criterion",
+ dataIndex: "criterion_name",
+ key: "criterion_name",
+ width: 160,
+ render: (v: string) => {v},
+ },
+ {
+ title: "Weight",
+ dataIndex: "weight",
+ key: "weight",
+ width: 65,
+ render: (v: number) =>
+ v != null ? (
+ {v}%
+ ) : null,
+ },
+ {
+ title: "Score",
+ dataIndex: "score",
+ key: "score",
+ width: 65,
+ render: (v: number) => (
+ = 70 ? "#52c41a" : v >= 50 ? "#faad14" : "#ff4d4f", fontWeight: 600 }}>
+ {v}
+
+ ),
+ },
+ {
+ title: (
+
+ Weighted
+
+ ),
+ key: "weighted",
+ width: 75,
+ render: (_: unknown, row: EvalVerdict) => {
+ if (row.weight == null) return null;
+ const contrib = (row.score * row.weight) / 100;
+ return (
+
+ {contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
+
+ );
+ },
+ },
+ {
+ title: "Comment",
+ dataIndex: "reasoning",
+ key: "reasoning",
+ ellipsis: { showTitle: false },
+ render: (v: string) => (
+
+ {v}
+
+ ),
+ },
+ ];
+
+ return (
+
+ {passed ? (
+
+ ) : (
+
+ )}
+ {entry.eval_name}
+ {passed ? "PASSED" : "FAILED"}
+
+
+ {entry.overall_score?.toFixed(0)} / 100
+ {entry.threshold != null && ` (threshold: ${entry.threshold})`}
+
+
+
+ }
+ extra={
+
+ {entry.judge_model && (
+
+ Judge: {entry.judge_model}
+
+ )}
+ {entry.iteration != null && (
+
+ Iter: {entry.iteration + 1}
+
+ )}
+
+ }
+ >
+ {entry.eval_error && (
+
+ Judge error: {entry.eval_error}
+
+ )}
+
+ {verdicts.length > 0 ? (
+ {
+ const hasWeights = verdicts.some((v) => v.weight != null);
+ if (!hasWeights) return null;
+ const total = verdicts.reduce(
+ (sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0),
+ 0
+ );
+ return (
+
+
+ Total
+
+
+
+
+
+ {total % 1 === 0 ? total : total.toFixed(1)}
+
+
+
+
+ );
+ }}
+ />
+ ) : (
+
+ Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available.
+
+ )}
+
+ );
+}