feat(ui): i18n for view_logs batch 2

This commit is contained in:
吹雪 2026-06-10 14:48:15 +09:00
parent e6704034de
commit 1a1144fe76
10 changed files with 384 additions and 82 deletions

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import {
checkEuAiActCompliance,
checkGdprCompliance,
@ -54,6 +55,7 @@ const ComplianceCard = ({
loading: boolean;
error: string | null;
}) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
@ -85,12 +87,12 @@ const ComplianceCard = ({
: "bg-red-100 text-red-700 border border-red-200"
}`}
>
{data.compliant ? "COMPLIANT" : "NON-COMPLIANT"}
{data.compliant ? t("viewLogs.compliancePanel.compliant") : t("viewLogs.compliancePanel.nonCompliant")}
</span>
)}
{error && (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-gray-100 text-gray-500 border border-gray-200">
UNAVAILABLE
{t("viewLogs.compliancePanel.unavailable")}
</span>
)}
<svg
@ -107,7 +109,7 @@ const ComplianceCard = ({
{expanded && (
<div className="border-t border-gray-100 px-4 py-3">
{loading && <p className="text-sm text-gray-500">Checking compliance...</p>}
{loading && <p className="text-sm text-gray-500">{t("viewLogs.compliancePanel.checkingCompliance")}</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{data && (
<div className="space-y-2">
@ -134,6 +136,7 @@ const ComplianceCard = ({
// -- Main Component --
const CompliancePanel: React.FC<CompliancePanelProps> = ({ accessToken, logEntry }) => {
const { t } = useTranslation();
const [euAiActData, setEuAiActData] = useState<ComplianceResponse | null>(null);
const [gdprData, setGdprData] = useState<ComplianceResponse | null>(null);
const [euAiActLoading, setEuAiActLoading] = useState(false);
@ -156,20 +159,22 @@ const CompliancePanel: React.FC<CompliancePanelProps> = ({ accessToken, logEntry
setEuAiActError(null);
checkEuAiActCompliance(accessToken, payload)
.then(setEuAiActData)
.catch((err) => setEuAiActError(err.message || "Failed to check EU AI Act compliance"))
.catch((err) => setEuAiActError(err.message || t("viewLogs.compliancePanel.euAiActCheckFailed")))
.finally(() => setEuAiActLoading(false));
setGdprLoading(true);
setGdprError(null);
checkGdprCompliance(accessToken, payload)
.then(setGdprData)
.catch((err) => setGdprError(err.message || "Failed to check GDPR compliance"))
.catch((err) => setGdprError(err.message || t("viewLogs.compliancePanel.gdprCheckFailed")))
.finally(() => setGdprLoading(false));
}, [accessToken, logEntry]);
return (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">Regulatory Compliance</h4>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
{t("viewLogs.compliancePanel.regulatoryCompliance")}
</h4>
<div className="space-y-3">
<ComplianceCard title="EU AI Act" data={euAiActData} loading={euAiActLoading} error={euAiActError} />
<ComplianceCard title="GDPR" data={gdprData} loading={gdprLoading} error={gdprError} />

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
export interface ContentFilterDetection {
type: "pattern" | "blocked_word" | "category_keyword";
@ -74,13 +75,15 @@ const KV: React.FC<KVProps> = ({ label, children, mono }) => (
);
const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response }) => {
const { t } = useTranslation();
// Handle case where response is a string (error message) or null
if (!response || typeof response === "string") {
if (typeof response === "string" && response) {
return (
<div className="bg-white rounded-lg border border-red-200 p-4">
<div className="text-red-800">
<h5 className="font-medium mb-2">Error</h5>
<h5 className="font-medium mb-2">{t("viewLogs.contentFilterDetails.errorTitle")}</h5>
<p className="text-sm">{response}</p>
</div>
</div>
@ -95,7 +98,7 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
if (detections.length === 0) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-gray-600 text-sm">No detections found</div>
<div className="text-gray-600 text-sm">{t("viewLogs.contentFilterDetails.noDetectionsFound")}</div>
</div>
);
}
@ -118,23 +121,31 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<KV label="Total Detections:">
<KV label={t("viewLogs.contentFilterDetails.totalDetections")}>
<span className="font-semibold">{totalDetections}</span>
</KV>
<KV label="Actions:">
<KV label={t("viewLogs.contentFilterDetails.actions")}>
<div className="flex flex-wrap gap-2">
{blockedCount > 0 && chip(`${blockedCount} blocked`, "red")}
{maskedCount > 0 && chip(`${maskedCount} masked`, "blue")}
{blockedCount === 0 && maskedCount === 0 && chip("passed", "green")}
{blockedCount > 0 &&
chip(t("viewLogs.contentFilterDetails.blocked_other", { count: blockedCount }), "red")}
{maskedCount > 0 &&
chip(t("viewLogs.contentFilterDetails.masked_other", { count: maskedCount }), "blue")}
{blockedCount === 0 && maskedCount === 0 && chip(t("viewLogs.contentFilterDetails.passed"), "green")}
</div>
</KV>
</div>
<div className="space-y-2">
<KV label="By Type:">
<KV label={t("viewLogs.contentFilterDetails.byType")}>
<div className="flex flex-wrap gap-2">
{patterns.length > 0 && chip(`${patterns.length} patterns`, "slate")}
{blockedWords.length > 0 && chip(`${blockedWords.length} keywords`, "slate")}
{categoryKeywords.length > 0 && chip(`${categoryKeywords.length} categories`, "slate")}
{patterns.length > 0 &&
chip(t("viewLogs.contentFilterDetails.patterns_other", { count: patterns.length }), "slate")}
{blockedWords.length > 0 &&
chip(t("viewLogs.contentFilterDetails.keywords_other", { count: blockedWords.length }), "slate")}
{categoryKeywords.length > 0 &&
chip(
t("viewLogs.contentFilterDetails.categories_other", { count: categoryKeywords.length }),
"slate",
)}
</div>
</KV>
</div>
@ -143,16 +154,20 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
{/* Patterns Section */}
{patterns.length > 0 && (
<Section title="Patterns Matched" count={patterns.length} defaultOpen={true}>
<Section title={t("viewLogs.contentFilterDetails.patternsMatched")} count={patterns.length} defaultOpen={true}>
<div className="space-y-2">
{patterns.map((detection, idx) => (
<div key={idx} className="p-3 bg-gray-50 rounded-md">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<KV label="Pattern:">{detection.pattern_name || "unknown"}</KV>
<KV label={t("viewLogs.contentFilterDetails.patternLabel")}>
{detection.pattern_name || t("viewLogs.contentFilterDetails.unknown")}
</KV>
</div>
<div className="space-y-1">
<KV label="Action:">{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}</KV>
<KV label={t("viewLogs.contentFilterDetails.actionLabel")}>
{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}
</KV>
</div>
</div>
</div>
@ -163,19 +178,27 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
{/* Blocked Words Section */}
{blockedWords.length > 0 && (
<Section title="Blocked Words Detected" count={blockedWords.length} defaultOpen={true}>
<Section
title={t("viewLogs.contentFilterDetails.blockedWordsDetected")}
count={blockedWords.length}
defaultOpen={true}
>
<div className="space-y-2">
{blockedWords.map((detection, idx) => (
<div key={idx} className="p-3 bg-gray-50 rounded-md">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<KV label="Keyword:" mono>
{detection.keyword || "unknown"}
<KV label={t("viewLogs.contentFilterDetails.keywordLabel")} mono>
{detection.keyword || t("viewLogs.contentFilterDetails.unknown")}
</KV>
{detection.description && <KV label="Description:">{detection.description}</KV>}
{detection.description && (
<KV label={t("viewLogs.contentFilterDetails.descriptionLabel")}>{detection.description}</KV>
)}
</div>
<div className="space-y-1">
<KV label="Action:">{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}</KV>
<KV label={t("viewLogs.contentFilterDetails.actionLabel")}>
{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}
</KV>
</div>
</div>
</div>
@ -186,18 +209,24 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
{/* Category Keywords Section */}
{categoryKeywords.length > 0 && (
<Section title="Category Keywords Detected" count={categoryKeywords.length} defaultOpen={true}>
<Section
title={t("viewLogs.contentFilterDetails.categoryKeywordsDetected")}
count={categoryKeywords.length}
defaultOpen={true}
>
<div className="space-y-2">
{categoryKeywords.map((detection, idx) => (
<div key={idx} className="p-3 bg-gray-50 rounded-md">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<KV label="Category:">{detection.category || "unknown"}</KV>
<KV label="Keyword:" mono>
{detection.keyword || "unknown"}
<KV label={t("viewLogs.contentFilterDetails.categoryLabel")}>
{detection.category || t("viewLogs.contentFilterDetails.unknown")}
</KV>
<KV label={t("viewLogs.contentFilterDetails.keywordLabel")} mono>
{detection.keyword || t("viewLogs.contentFilterDetails.unknown")}
</KV>
{detection.severity && (
<KV label="Severity:">
<KV label={t("viewLogs.contentFilterDetails.severityLabel")}>
{chip(
detection.severity,
detection.severity === "high" ? "red" : detection.severity === "medium" ? "amber" : "slate",
@ -206,7 +235,9 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
)}
</div>
<div className="space-y-1">
<KV label="Action:">{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}</KV>
<KV label={t("viewLogs.contentFilterDetails.actionLabel")}>
{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}
</KV>
</div>
</div>
</div>
@ -216,7 +247,7 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
)}
{/* Raw JSON (for debugging) */}
<Section title="Raw Detection Data" defaultOpen={false}>
<Section title={t("viewLogs.contentFilterDetails.rawDetectionData")} defaultOpen={false}>
<pre className="bg-gray-50 rounded p-3 text-xs overflow-x-auto">{JSON.stringify(detections, null, 2)}</pre>
</Section>
</div>

View file

@ -1,5 +1,6 @@
import React, { useState, useMemo } from "react";
import { Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import PresidioDetectedEntities from "./PresidioDetectedEntities";
import BedrockGuardrailDetails, {
BedrockGuardrailResponse,
@ -250,19 +251,23 @@ const ExternalLinkIcon = () => (
// ── Sub-components ──────────────────────────────────────────────────────────
const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => {
const { t } = useTranslation();
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>
<h5 className="text-sm font-medium mb-2 text-gray-700">
{t("viewLogs.guardrailViewer.matchDetails", { count: 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>
<th className="pb-2 pr-4 font-medium">{t("viewLogs.guardrailViewer.matchDetailsHeader.type")}</th>
<th className="pb-2 pr-4 font-medium">{t("viewLogs.guardrailViewer.matchDetailsHeader.method")}</th>
<th className="pb-2 pr-4 font-medium">{t("viewLogs.guardrailViewer.matchDetailsHeader.action")}</th>
<th className="pb-2 font-medium">{t("viewLogs.guardrailViewer.matchDetailsHeader.detail")}</th>
</tr>
</thead>
<tbody>
@ -297,6 +302,7 @@ const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) =>
};
const GenericGuardrailResponse = ({ response }: { response: any }) => {
const { t } = useTranslation();
const [showRaw, setShowRaw] = useState(false);
return (
<div className="mt-3">
@ -307,7 +313,7 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => {
>
<div className="flex items-center">
<ChevronIcon expanded={showRaw} />
<h5 className="font-medium text-sm ml-1">Raw Guardrail Response</h5>
<h5 className="font-medium text-sm ml-1">{t("viewLogs.guardrailViewer.rawGuardrailResponse")}</h5>
</div>
</div>
{showRaw && (
@ -331,6 +337,7 @@ interface TimelineEntry {
}
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const { t } = useTranslation();
const sorted = useMemo(() => [...entries].sort((a, b) => (a.start_time ?? 0) - (b.start_time ?? 0)), [entries]);
const timeline = useMemo(() => {
@ -340,7 +347,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const items: TimelineEntry[] = [];
// Request received
items.push({ type: "request", label: "Request received", offsetMs: 0 });
items.push({ type: "request", label: t("viewLogs.guardrailViewer.requestReceived"), offsetMs: 0 });
// Pre-call guardrails — use modeMatches so array modes (e.g. ["pre_call", "post_call"])
// place the entry in every matching bucket.
@ -354,9 +361,9 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
items.push({
type: "guardrail",
label: `Pre-call guardrail: ${getDisplayName(e)}`,
label: t("viewLogs.guardrailViewer.preCallGuardrail", { name: getDisplayName(e) }),
offsetMs,
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
status: isEntrySuccess(e) ? t("viewLogs.guardrailViewer.passed") : t("viewLogs.guardrailViewer.failed"),
isSuccess: isEntrySuccess(e),
});
}
@ -369,7 +376,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
items.push({
type: "llm",
label: "LLM call",
label: t("viewLogs.guardrailViewer.llmCall"),
offsetMs: llmOffsetMs,
});
@ -378,9 +385,9 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
items.push({
type: "guardrail",
label: `During-call guardrail: ${getDisplayName(e)}`,
label: t("viewLogs.guardrailViewer.duringCallGuardrail", { name: getDisplayName(e) }),
offsetMs,
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
status: isEntrySuccess(e) ? t("viewLogs.guardrailViewer.passed") : t("viewLogs.guardrailViewer.failed"),
isSuccess: isEntrySuccess(e),
});
}
@ -390,9 +397,9 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
items.push({
type: "guardrail",
label: `Post-call guardrail: ${getDisplayName(e)}`,
label: t("viewLogs.guardrailViewer.postCallGuardrail", { name: getDisplayName(e) }),
offsetMs,
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
status: isEntrySuccess(e) ? t("viewLogs.guardrailViewer.passed") : t("viewLogs.guardrailViewer.failed"),
isSuccess: isEntrySuccess(e),
});
}
@ -400,14 +407,16 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
// 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 });
items.push({ type: "response", label: t("viewLogs.guardrailViewer.responseReturned"), offsetMs: responseOffsetMs });
return items;
}, [sorted]);
}, [sorted, t]);
return (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">Request Lifecycle</h4>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
{t("viewLogs.guardrailViewer.requestLifecycle")}
</h4>
<div className="relative">
{timeline.map((item, idx) => (
<div key={idx} className="flex items-start gap-3 relative">
@ -457,6 +466,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
// ── Evaluation Card ─────────────────────────────────────────────────────────
const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const success = isEntrySuccess(entry);
const totalMasked = getTotalMasked(entry);
@ -509,7 +519,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
: "bg-red-100 text-red-700 border border-red-200"
}`}
>
{success ? "PASSED" : "FAILED"}
{success ? t("viewLogs.guardrailViewer.passed") : t("viewLogs.guardrailViewer.failed")}
</span>
{matchCountStr && (
@ -526,16 +536,18 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
{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
{t("viewLogs.guardrailViewer.confidencePercent", {
percent: (entry.confidence_score * 100).toFixed(0),
})}
</span>
)}
{riskScore != null && success && (
<Tooltip title={`Risk score: ${riskScore}/10`}>
<Tooltip title={t("viewLogs.guardrailViewer.riskScoreTooltip", { score: riskScore })}>
<span
className={`px-2 py-0.5 border rounded text-[11px] font-semibold flex-shrink-0 ${getRiskColor(riskScore)}`}
>
Risk {riskScore}/10
{t("viewLogs.guardrailViewer.riskScore", { score: riskScore })}
</span>
</Tooltip>
)}
@ -559,28 +571,36 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
{/* 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>
<h5 className="text-sm font-medium text-gray-700 mb-2">{t("viewLogs.guardrailViewer.classification")}</h5>
{entry.classification.category && (
<div className="flex text-sm">
<span className="font-medium w-1/3 text-gray-500">Category:</span>
<span className="font-medium w-1/3 text-gray-500">
{t("viewLogs.guardrailViewer.classificationCategory")}
</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-medium w-1/3 text-gray-500">
{t("viewLogs.guardrailViewer.classificationReference")}
</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 className="font-medium w-1/3 text-gray-500">
{t("viewLogs.guardrailViewer.classificationConfidence")}
</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 className="font-medium w-1/3 text-gray-500">
{t("viewLogs.guardrailViewer.classificationReason")}
</span>
<span>{entry.classification.reason}</span>
</div>
)}
@ -595,7 +615,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
{/* Masked entity summary */}
{totalMasked > 0 && (
<div className="mt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">Masked Entities</h5>
<h5 className="text-sm font-medium text-gray-700 mb-2">{t("viewLogs.guardrailViewer.maskedEntities")}</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">
@ -634,6 +654,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
// ── Main Component ──────────────────────────────────────────────────────────
const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps) => {
const { t } = useTranslation();
const guardrailEntries = useMemo(() => {
return Array.isArray(data)
? data.filter((entry): entry is GuardrailInformation => Boolean(entry))
@ -676,10 +697,10 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
<div className="flex items-center gap-4">
<ShieldIcon />
<div>
<h3 className="text-lg font-semibold text-gray-900">Guardrails &amp; Policy Compliance</h3>
<h3 className="text-lg font-semibold text-gray-900">{t("viewLogs.guardrailViewer.headerTitle")}</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
{t("viewLogs.guardrailViewer.guardrailsEvaluated_other", { count: guardrailEntries.length })}
</span>
<span className="text-gray-300">|</span>
<span
@ -700,7 +721,7 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
/>
</svg>
) : null}
{passedCount} Passed
{t("viewLogs.guardrailViewer.passedCount", { count: passedCount })}
</span>
</div>
</div>
@ -708,7 +729,9 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
<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>
<div className="text-sm font-medium text-gray-900">
{t("viewLogs.guardrailViewer.totalOverhead", { ms: totalOverheadMs })}
</div>
</div>
<button
@ -716,7 +739,7 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
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
{t("viewLogs.guardrailViewer.exportComplianceLog")}
</button>
</div>
</div>
@ -737,7 +760,9 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
{/* Evaluation Details */}
<div className="px-6 py-5">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">Evaluation Details</h4>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
{t("viewLogs.guardrailViewer.evaluationDetails")}
</h4>
<div className="space-y-3">
{guardrailEntries.map((entry, index) => (
<EvaluationCard key={`${entry.guardrail_name ?? "guardrail"}-${index}`} entry={entry} />

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
interface RecognitionMetadata {
recognizer_name: string;
@ -24,6 +25,7 @@ const getScoreColor = (score: number): string => {
};
const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) => {
const { t } = useTranslation();
const [entityListExpanded, setEntityListExpanded] = useState(true);
const [expandedEntities, setExpandedEntities] = useState<Record<number, boolean>>({});
@ -47,7 +49,9 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h4 className="font-medium">Detected Entities ({entities.length})</h4>
<h4 className="font-medium">
{t("viewLogs.presidioDetectedEntities.detectedEntities", { count: entities.length })}
</h4>
</div>
{entityListExpanded && (
@ -71,10 +75,15 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium mr-2">{entity.entity_type}</span>
<span className={`font-mono ${getScoreColor(entity.score)}`}>Score: {entity.score.toFixed(2)}</span>
<span className={`font-mono ${getScoreColor(entity.score)}`}>
{t("viewLogs.presidioDetectedEntities.scoreLabel", { score: entity.score.toFixed(2) })}
</span>
</div>
<span className="text-xs text-gray-500">
Position: {entity.start}-{entity.end}
{t("viewLogs.presidioDetectedEntities.positionRange", {
start: entity.start,
end: entity.end,
})}
</span>
</div>
@ -83,17 +92,26 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
<div className="grid grid-cols-2 gap-4 mb-2">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Entity Type:</span>
<span className="font-medium w-1/3">
{t("viewLogs.presidioDetectedEntities.entityTypeLabel")}
</span>
<span>{entity.entity_type}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Position:</span>
<span className="font-medium w-1/3">
{t("viewLogs.presidioDetectedEntities.positionLabel")}
</span>
<span>
Characters {entity.start}-{entity.end}
{t("viewLogs.presidioDetectedEntities.characters", {
start: entity.start,
end: entity.end,
})}
</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Confidence:</span>
<span className="font-medium w-1/3">
{t("viewLogs.presidioDetectedEntities.confidenceLabel")}
</span>
<span className={getScoreColor(entity.score)}>{entity.score.toFixed(2)}</span>
</div>
</div>
@ -102,11 +120,15 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
{entity.recognition_metadata && (
<>
<div className="flex">
<span className="font-medium w-1/3">Recognizer:</span>
<span className="font-medium w-1/3">
{t("viewLogs.presidioDetectedEntities.recognizerLabel")}
</span>
<span>{entity.recognition_metadata.recognizer_name}</span>
</div>
<div className="flex overflow-hidden">
<span className="font-medium w-1/3">Identifier:</span>
<span className="font-medium w-1/3">
{t("viewLogs.presidioDetectedEntities.identifierLabel")}
</span>
<span className="truncate text-xs font-mono">
{entity.recognition_metadata.recognizer_identifier}
</span>
@ -115,7 +137,9 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
)}
{entity.analysis_explanation && (
<div className="flex">
<span className="font-medium w-1/3">Explanation:</span>
<span className="font-medium w-1/3">
{t("viewLogs.presidioDetectedEntities.explanationLabel")}
</span>
<span>{entity.analysis_explanation}</span>
</div>
)}

View file

@ -6,6 +6,7 @@
import { useState } from "react";
import { Typography } from "antd";
import { DownOutlined, RightOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
const { Text } = Typography;
@ -16,6 +17,7 @@ interface CollapsibleMessageProps {
}
export function CollapsibleMessage({ label, content, defaultExpanded = false }: CollapsibleMessageProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [isHovered, setIsHovered] = useState(false);
const charCount = content?.length || 0;
@ -52,7 +54,7 @@ export function CollapsibleMessage({ label, content, defaultExpanded = false }:
{label}
</Text>
<Text type="secondary" style={{ fontSize: 10 }}>
({charCount.toLocaleString()} chars)
{t("viewLogs.collapsibleMessage.charCount", { count: charCount.toLocaleString() })}
</Text>
</div>

View file

@ -1,6 +1,7 @@
import { Button, Space, Tag, Tooltip, Typography } from "antd";
import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
import moment from "moment";
import { useTranslation } from "react-i18next";
import { LogEntry } from "../columns";
import { getProviderLogoAndName } from "../../provider_info_helpers";
import {
@ -40,6 +41,7 @@ export function DrawerHeader({
statusColor,
environment,
}: DrawerHeaderProps) {
const { t } = useTranslation();
const provider = log.custom_llm_provider || "";
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
@ -87,12 +89,13 @@ function ModelProviderSection({
providerLogo?: string;
providerName?: string;
}) {
const { t } = useTranslation();
return (
<Space size={SPACING_MEDIUM} style={{ marginBottom: SPACING_MEDIUM }}>
{providerLogo && (
<img
src={providerLogo}
alt={providerName || "Provider"}
alt={providerName || t("viewLogs.drawerHeader.providerAlt")}
style={{ width: 24, height: 24 }}
onError={(e) => {
const target = e.target as HTMLImageElement;
@ -118,12 +121,16 @@ function ModelProviderSection({
* Request ID display with copy functionality
*/
function RequestIdSection({ requestId }: { requestId: string }) {
const { t } = useTranslation();
return (
<div style={{ flex: 1, minWidth: 0 }}>
<Tooltip title={requestId}>
<Text
strong
copyable={{ text: requestId, tooltips: ["Copy Request ID", "Copied!"] }}
copyable={{
text: requestId,
tooltips: [t("viewLogs.drawerHeader.copyRequestId"), t("viewLogs.drawerHeader.copiedRequestId")],
}}
style={{
fontSize: FONT_SIZE_HEADER,
fontFamily: FONT_FAMILY_MONO,
@ -153,6 +160,7 @@ function NavigationSection({
onNext: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const keyboardShortcutStyle = {
border: "1px solid #d9d9d9",
borderRadius: 4,
@ -173,7 +181,7 @@ function NavigationSection({
<DownOutlined />
<span style={keyboardShortcutStyle}>J</span>
</Button>
<Tooltip title="ESC to close">
<Tooltip title={t("viewLogs.drawerHeader.escToClose")}>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
</Tooltip>
</Space>
@ -194,10 +202,11 @@ function StatusBar({
statusColor: "error" | "success";
environment: string;
}) {
const { t } = useTranslation();
return (
<Space size={SPACING_LARGE}>
<Tag color={statusColor}>{statusLabel}</Tag>
<Tag>Env: {environment}</Tag>
<Tag>{t("viewLogs.drawerHeader.envTag", { env: environment })}</Tag>
<Space size={SPACING_MEDIUM}>
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}

View file

@ -6,6 +6,7 @@
import { useState } from "react";
import { Typography } from "antd";
import { DownOutlined, RightOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { ParsedMessage } from "./prettyMessagesTypes";
import { SimpleMessageBlock } from "./SimpleMessageBlock";
@ -16,6 +17,7 @@ interface HistoryTreeProps {
}
export function HistoryTree({ messages }: HistoryTreeProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@ -48,7 +50,7 @@ export function HistoryTree({ messages }: HistoryTreeProps) {
<RightOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
)}
<Text type="secondary" style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}>
HISTORY ({messages.length} message{messages.length !== 1 ? "s" : ""})
{t("viewLogs.historyTree.history_other", { count: messages.length })}
</Text>
</div>

View file

@ -1,6 +1,7 @@
import { Typography } from "antd";
import { JsonView, defaultStyles } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css";
import { useTranslation } from "react-i18next";
import { JSON_MAX_HEIGHT, COLOR_BG_LIGHT, SPACING_LARGE } from "./constants";
const { Text } = Typography;
@ -15,7 +16,8 @@ interface JsonViewerProps {
* Uses an interactive tree component for easy navigation.
*/
export function JsonViewer({ data }: JsonViewerProps) {
if (!data) return <Text type="secondary">No data</Text>;
const { t } = useTranslation();
if (!data) return <Text type="secondary">{t("common.noData")}</Text>;
return (
<div

View file

@ -2074,5 +2074,106 @@
"dailyActiveUsers": "Daily Active Users - Last 7 Days",
"weeklyActiveUsers": "Weekly Active Users - Last 7 Weeks",
"monthlyActiveUsers": "Monthly Active Users - Last 7 Months"
},
"viewLogs": {
"compliancePanel": {
"checkingCompliance": "Checking compliance...",
"compliant": "COMPLIANT",
"nonCompliant": "NON-COMPLIANT",
"unavailable": "UNAVAILABLE",
"regulatoryCompliance": "Regulatory Compliance",
"euAiActCheckFailed": "Failed to check EU AI Act compliance",
"gdprCheckFailed": "Failed to check GDPR compliance"
},
"contentFilterDetails": {
"errorTitle": "Error",
"noDetectionsFound": "No detections found",
"totalDetections": "Total Detections:",
"actions": "Actions:",
"byType": "By Type:",
"patternsMatched": "Patterns Matched",
"blockedWordsDetected": "Blocked Words Detected",
"categoryKeywordsDetected": "Category Keywords Detected",
"rawDetectionData": "Raw Detection Data",
"patternLabel": "Pattern:",
"keywordLabel": "Keyword:",
"descriptionLabel": "Description:",
"categoryLabel": "Category:",
"severityLabel": "Severity:",
"actionLabel": "Action:",
"unknown": "unknown",
"passed": "passed",
"blocked_one": "{{count}} blocked",
"blocked_other": "{{count}} blocked",
"masked_one": "{{count}} masked",
"masked_other": "{{count}} masked",
"patterns_one": "{{count}} patterns",
"patterns_other": "{{count}} patterns",
"keywords_one": "{{count}} keywords",
"keywords_other": "{{count}} keywords",
"categories_one": "{{count}} categories",
"categories_other": "{{count}} categories"
},
"guardrailViewer": {
"requestLifecycle": "Request Lifecycle",
"evaluationDetails": "Evaluation Details",
"requestReceived": "Request received",
"llmCall": "LLM call",
"responseReturned": "Response returned",
"preCallGuardrail": "Pre-call guardrail: {{name}}",
"duringCallGuardrail": "During-call guardrail: {{name}}",
"postCallGuardrail": "Post-call guardrail: {{name}}",
"passed": "PASSED",
"failed": "FAILED",
"rawGuardrailResponse": "Raw Guardrail Response",
"matchDetails": "Match Details ({{count}})",
"matchDetailsHeader": {
"type": "Type",
"method": "Method",
"action": "Action",
"detail": "Detail"
},
"classification": "Classification",
"classificationCategory": "Category:",
"classificationReference": "Reference:",
"classificationConfidence": "Confidence:",
"classificationReason": "Reason:",
"maskedEntities": "Masked Entities",
"headerTitle": "Guardrails & Policy Compliance",
"guardrailsEvaluated_one": "{{count}} guardrail evaluated",
"guardrailsEvaluated_other": "{{count}} guardrails evaluated",
"passedCount": "{{count}} Passed",
"totalOverhead": "Total: {{ms}}ms overhead",
"exportComplianceLog": "Export Compliance Log",
"riskScore": "Risk {{score}}/10",
"riskScoreTooltip": "Risk score: {{score}}/10",
"confidencePercent": "{{percent}}% conf"
},
"presidioDetectedEntities": {
"detectedEntities": "Detected Entities ({{count}})",
"scoreLabel": "Score: {{score}}",
"positionRange": "Position: {{start}}-{{end}}",
"entityTypeLabel": "Entity Type:",
"positionLabel": "Position:",
"characters": "Characters {{start}}-{{end}}",
"confidenceLabel": "Confidence:",
"recognizerLabel": "Recognizer:",
"identifierLabel": "Identifier:",
"explanationLabel": "Explanation:"
},
"collapsibleMessage": {
"charCount": "({{count}} chars)"
},
"drawerHeader": {
"copyRequestId": "Copy Request ID",
"copiedRequestId": "Copied!",
"escToClose": "ESC to close",
"envTag": "Env: {{env}}",
"providerAlt": "Provider"
},
"historyTree": {
"history_one": "HISTORY ({{count}} message)",
"history_other": "HISTORY ({{count}} messages)"
}
}
}

View file

@ -2074,5 +2074,106 @@
"dailyActiveUsers": "每日活跃用户 - 近 7 天",
"weeklyActiveUsers": "每周活跃用户 - 近 7 周",
"monthlyActiveUsers": "每月活跃用户 - 近 7 个月"
},
"viewLogs": {
"compliancePanel": {
"checkingCompliance": "正在检查合规性...",
"compliant": "合规",
"nonCompliant": "不合规",
"unavailable": "不可用",
"regulatoryCompliance": "监管合规",
"euAiActCheckFailed": "检查 EU AI Act 合规性失败",
"gdprCheckFailed": "检查 GDPR 合规性失败"
},
"contentFilterDetails": {
"errorTitle": "错误",
"noDetectionsFound": "未发现任何检测结果",
"totalDetections": "检测总数:",
"actions": "操作:",
"byType": "按类型:",
"patternsMatched": "匹配的模式",
"blockedWordsDetected": "检测到的屏蔽词",
"categoryKeywordsDetected": "检测到的分类关键词",
"rawDetectionData": "原始检测数据",
"patternLabel": "模式:",
"keywordLabel": "关键词:",
"descriptionLabel": "描述:",
"categoryLabel": "分类:",
"severityLabel": "严重程度:",
"actionLabel": "操作:",
"unknown": "未知",
"passed": "通过",
"blocked_one": "{{count}} 个已屏蔽",
"blocked_other": "{{count}} 个已屏蔽",
"masked_one": "{{count}} 个已脱敏",
"masked_other": "{{count}} 个已脱敏",
"patterns_one": "{{count}} 个模式",
"patterns_other": "{{count}} 个模式",
"keywords_one": "{{count}} 个关键词",
"keywords_other": "{{count}} 个关键词",
"categories_one": "{{count}} 个分类",
"categories_other": "{{count}} 个分类"
},
"guardrailViewer": {
"requestLifecycle": "请求生命周期",
"evaluationDetails": "评估详情",
"requestReceived": "收到请求",
"llmCall": "LLM 调用",
"responseReturned": "返回响应",
"preCallGuardrail": "前置护栏:{{name}}",
"duringCallGuardrail": "调用中护栏:{{name}}",
"postCallGuardrail": "后置护栏:{{name}}",
"passed": "通过",
"failed": "失败",
"rawGuardrailResponse": "原始护栏响应",
"matchDetails": "匹配详情({{count}})",
"matchDetailsHeader": {
"type": "类型",
"method": "方法",
"action": "操作",
"detail": "详情"
},
"classification": "分类",
"classificationCategory": "分类:",
"classificationReference": "引用:",
"classificationConfidence": "置信度:",
"classificationReason": "原因:",
"maskedEntities": "已脱敏实体",
"headerTitle": "护栏与策略合规",
"guardrailsEvaluated_one": "已评估 {{count}} 个护栏",
"guardrailsEvaluated_other": "已评估 {{count}} 个护栏",
"passedCount": "{{count}} 个通过",
"totalOverhead": "总计:{{ms}}ms 开销",
"exportComplianceLog": "导出合规日志",
"riskScore": "风险 {{score}}/10",
"riskScoreTooltip": "风险评分:{{score}}/10",
"confidencePercent": "{{percent}}% 置信度"
},
"presidioDetectedEntities": {
"detectedEntities": "检测到的实体({{count}})",
"scoreLabel": "得分:{{score}}",
"positionRange": "位置:{{start}}-{{end}}",
"entityTypeLabel": "实体类型:",
"positionLabel": "位置:",
"characters": "字符 {{start}}-{{end}}",
"confidenceLabel": "置信度:",
"recognizerLabel": "识别器:",
"identifierLabel": "标识符:",
"explanationLabel": "说明:"
},
"collapsibleMessage": {
"charCount": "({{count}} 字符)"
},
"drawerHeader": {
"copyRequestId": "复制请求 ID",
"copiedRequestId": "已复制!",
"escToClose": "按 ESC 关闭",
"envTag": "环境:{{env}}",
"providerAlt": "提供商"
},
"historyTree": {
"history_one": "历史({{count}} 条消息)",
"history_other": "历史({{count}} 条消息)"
}
}
}