feat(ui): i18n for bulk user creation and cache views

This commit is contained in:
吹雪 2026-06-10 13:07:59 +09:00
parent b2abda084f
commit eb53553d24
5 changed files with 416 additions and 141 deletions

View file

@ -14,6 +14,7 @@ import Papa from "papaparse";
import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline";
import { CopyToClipboard } from "react-copy-to-clipboard";
import NotificationsManager from "./molecules/notifications_manager";
import { useTranslation } from "react-i18next";
interface BulkCreateUsersProps {
accessToken: string;
@ -52,6 +53,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
possibleUIRoles,
onUsersCreated,
}) => {
const { t } = useTranslation();
const [isModalVisible, setIsModalVisible] = useState(false);
const [parsedData, setParsedData] = useState<UserData[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
@ -109,16 +111,14 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Check file type
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`);
NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file.");
setFileError(t("bulkCreateUsers.invalidFileType", { name: file.name }));
NotificationsManager.fromBackend(t("bulkCreateUsers.invalidFileTypeShort"));
return false;
}
// Check file size (limit to 5MB)
if (file.size > 5 * 1024 * 1024) {
setFileError(
`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`,
);
setFileError(t("bulkCreateUsers.fileTooLarge", { size: (file.size / (1024 * 1024)).toFixed(1) }));
return false;
}
@ -126,16 +126,14 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
complete: (results) => {
// Check if file is empty
if (!results.data || results.data.length === 0) {
setCsvStructureError("The CSV file appears to be empty. Please upload a file with data.");
setCsvStructureError(t("bulkCreateUsers.csvEmpty"));
setParsedData([]);
return;
}
// Check if there's only header row
if (results.data.length === 1) {
setCsvStructureError(
"The CSV file only contains headers but no user data. Please add user data to your CSV.",
);
setCsvStructureError(t("bulkCreateUsers.csvHeaderOnly"));
setParsedData([]);
return;
}
@ -144,9 +142,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Check if headers exist
if (headers.length === 0 || (headers.length === 1 && headers[0] === "")) {
setCsvStructureError(
"The CSV file doesn't contain any column headers. Please make sure your CSV has headers.",
);
setCsvStructureError(t("bulkCreateUsers.csvNoHeaders"));
setParsedData([]);
return;
}
@ -156,9 +152,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Check if all required columns are present
const missingColumns = requiredColumns.filter((col) => !headers.includes(col));
if (missingColumns.length > 0) {
setCsvStructureError(
`Your CSV is missing these required columns: ${missingColumns.join(", ")}. Please add these columns to your CSV file.`,
);
setCsvStructureError(t("bulkCreateUsers.csvMissingColumns", { columns: missingColumns.join(", ") }));
setParsedData([]);
return;
}
@ -177,7 +171,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
return {
rowNumber: index + 2,
isValid: false,
error: `Row ${index + 2} has fewer columns than the header row. Please ensure all data is properly formatted.`,
error: t("bulkCreateUsers.rowTooFewColumns", { row: index + 2 }),
user_email: "",
user_role: "",
} as UserData;
@ -200,36 +194,36 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Email validation
if (!user.user_email) {
errors.push("Email is required");
errors.push(t("bulkCreateUsers.emailRequired"));
} else if (!user.user_email.includes("@") || !user.user_email.includes(".")) {
errors.push("Invalid email format (must contain @ and domain)");
errors.push(t("bulkCreateUsers.emailInvalid"));
}
// Role validation
if (!user.user_role) {
errors.push("Role is required");
errors.push(t("bulkCreateUsers.roleRequired"));
} else {
// Validate user role
const validRoles = ["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"];
if (!validRoles.includes(user.user_role)) {
errors.push(`Invalid role "${user.user_role}". Must be one of: ${validRoles.join(", ")}`);
errors.push(
t("bulkCreateUsers.roleInvalid", { role: user.user_role, validRoles: validRoles.join(", ") }),
);
}
}
// Budget validation
if (user.max_budget && user.max_budget.toString().trim() !== "") {
if (isNaN(parseFloat(user.max_budget.toString()))) {
errors.push(`Max budget "${user.max_budget}" must be a number`);
errors.push(t("bulkCreateUsers.budgetNotNumber", { budget: user.max_budget }));
} else if (parseFloat(user.max_budget.toString()) <= 0) {
errors.push("Max budget must be greater than 0");
errors.push(t("bulkCreateUsers.budgetMustBePositive"));
}
}
// Budget duration validation
if (user.budget_duration && !user.budget_duration.match(/^\d+[dhmwy]$|^\d+mo$/)) {
errors.push(
`Invalid budget duration format "${user.budget_duration}". Use format like "30d", "1mo", "2w", "6h"`,
);
errors.push(t("bulkCreateUsers.budgetDurationInvalid", { duration: user.budget_duration }));
}
// Teams validation
@ -240,7 +234,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
const userTeams = user.teams.split(",").map((t) => t.trim());
const invalidTeams = userTeams.filter((t) => !teamIds.includes(t));
if (invalidTeams.length > 0) {
errors.push(`Unknown team(s): ${invalidTeams.join(", ")}`);
errors.push(t("bulkCreateUsers.unknownTeams", { teams: invalidTeams.join(", ") }));
}
}
}
@ -258,24 +252,27 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setParsedData(userData);
if (userData.length === 0) {
setCsvStructureError("No valid data rows found in the CSV file. Please check your file format.");
setCsvStructureError(t("bulkCreateUsers.noDataRows"));
} else if (validData.length === 0) {
setParseError("No valid users found in the CSV. Please check the errors below and fix your CSV file.");
setParseError(t("bulkCreateUsers.noValidUsers"));
} else if (validData.length < userData.length) {
setParseError(
`Found ${userData.length - validData.length} row(s) with errors out of ${userData.length} total rows. Please correct them before proceeding.`,
t("bulkCreateUsers.someRowsHaveErrors", {
errorCount: userData.length - validData.length,
total: userData.length,
}),
);
} else {
NotificationsManager.success(`Successfully parsed ${validData.length} users`);
NotificationsManager.success(t("bulkCreateUsers.parsedSuccess", { count: validData.length }));
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setParseError(`Error parsing CSV: ${errorMessage}`);
setParseError(t("bulkCreateUsers.parseError", { error: errorMessage }));
setParsedData([]);
}
},
error: (error) => {
setParseError(`Failed to parse CSV file: ${error.message}`);
setParseError(t("bulkCreateUsers.parseFileFailed", { error: error.message }));
setParsedData([]);
},
header: false,
@ -404,7 +401,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
...u,
status: "success",
key: response.key || response.user_id,
error: "User created but failed to generate invitation link",
error: t("bulkCreateUsers.invitationLinkFailed"),
}
: u,
),
@ -412,7 +409,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
}
} else {
console.log("Error case triggered");
const errorMessage = response?.error || "Failed to create user";
const errorMessage = response?.error || t("bulkCreateUsers.failedToCreateUser");
console.log("Error message:", errorMessage);
setParsedData((current) =>
current.map((u, i) => (i === index ? { ...u, status: "failed", error: errorMessage } : u)),
@ -459,33 +456,33 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
const columns = [
{
title: "Row",
title: t("bulkCreateUsers.colRow"),
dataIndex: "rowNumber",
key: "rowNumber",
width: 80,
},
{
title: "Email",
title: t("bulkCreateUsers.colEmail"),
dataIndex: "user_email",
key: "user_email",
},
{
title: "Role",
title: t("bulkCreateUsers.colRole"),
dataIndex: "user_role",
key: "user_role",
},
{
title: "Teams",
title: t("bulkCreateUsers.colTeams"),
dataIndex: "teams",
key: "teams",
},
{
title: "Budget",
title: t("bulkCreateUsers.colBudget"),
dataIndex: "max_budget",
key: "max_budget",
},
{
title: "Status",
title: t("common.status"),
key: "status",
render: (_: any, record: UserData) => {
if (!record.isValid) {
@ -493,21 +490,21 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Invalid</span>
<span className="text-red-500">{t("bulkCreateUsers.statusInvalid")}</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-gray-500">Pending</span>;
return <span className="text-gray-500">{t("bulkCreateUsers.statusPending")}</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<span className="text-green-500">Success</span>
<span className="text-green-500">{t("common.success")}</span>
</div>
{record.invitation_link && (
<div className="mt-1">
@ -515,9 +512,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard
text={record.invitation_link}
onCopy={() => NotificationsManager.success("Invitation link copied!")}
onCopy={() => NotificationsManager.success(t("bulkCreateUsers.invitationLinkCopied"))}
>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">{t("common.copy")}</button>
</CopyToClipboard>
</div>
</div>
@ -529,7 +526,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Failed</span>
<span className="text-red-500">{t("bulkCreateUsers.statusFailed")}</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
</div>
@ -541,11 +538,11 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
return (
<>
<Button type="primary" className="mb-0" onClick={() => setIsModalVisible(true)}>
+ Bulk Invite Users
{t("bulkCreateUsers.openButton")}
</Button>
<Modal
title="Bulk Invite Users"
title={t("bulkCreateUsers.modalTitle")}
open={isModalVisible}
width={800}
onCancel={() => setIsModalVisible(false)}
@ -560,77 +557,68 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
<h3 className="text-lg font-medium">{t("bulkCreateUsers.step1Title")}</h3>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<p className="mb-4">{t("bulkCreateUsers.step1Desc")}</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
<li>{t("bulkCreateUsers.step1Item1")}</li>
<li>{t("bulkCreateUsers.step1Item2")}</li>
<li>{t("bulkCreateUsers.step1Item3")}</li>
<li>{t("bulkCreateUsers.step1Item4")}</li>
</ol>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<h4 className="font-medium mb-2">{t("bulkCreateUsers.templateColumnsTitle")}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">user_email</p>
<p className="text-sm text-gray-600">User&apos;s email address (required)</p>
<p className="text-sm text-gray-600">{t("bulkCreateUsers.colUserEmailDesc")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-gray-600">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
<p className="text-sm text-gray-600">{t("bulkCreateUsers.colUserRoleDesc")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-gray-600">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
<p className="text-sm text-gray-600">{t("bulkCreateUsers.colTeamsDesc")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., &quot;100&quot;)</p>
<p className="text-sm text-gray-600">{t("bulkCreateUsers.colMaxBudgetDesc")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-gray-600">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
</p>
<p className="text-sm text-gray-600">{t("bulkCreateUsers.colBudgetDurationDesc")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-gray-600">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
</p>
<p className="text-sm text-gray-600">{t("bulkCreateUsers.colModelsDesc")}</p>
</div>
</div>
</div>
</div>
<Button type="primary" size="large" className="w-full md:w-auto" icon={<DownloadOutlined />}>
Download CSV Template
{t("bulkCreateUsers.downloadTemplateButton")}
</Button>
</div>
@ -638,7 +626,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
<h3 className="text-lg font-medium">{t("bulkCreateUsers.step2Title")}</h3>
</div>
<div className="ml-11">
@ -668,7 +656,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
className="flex items-center"
icon={<DeleteOutlined />}
>
Remove
{t("common.remove")}
</Button>
</div>
@ -683,7 +671,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-blue-600">Processing...</span>
<span className="ml-2 text-xs text-blue-600">{t("bulkCreateUsers.processing")}</span>
</div>
)
)}
@ -692,10 +680,10 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<Upload beforeUpload={handleFileUpload} accept=".csv" maxCount={1} showUploadList={false}>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer">
<UploadOutlined className="text-3xl text-gray-400 mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-gray-500 mb-3">or</p>
<Button size="small">Browse files</Button>
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
<p className="mb-1">{t("bulkCreateUsers.dropzone")}</p>
<p className="text-sm text-gray-500 mb-3">{t("bulkCreateUsers.dropzoneOr")}</p>
<Button size="small">{t("bulkCreateUsers.browseFiles")}</Button>
<p className="text-xs text-gray-500 mt-4">{t("bulkCreateUsers.csvOnly")}</p>
</div>
</Upload>
)}
@ -706,13 +694,13 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<ExclamationIcon className="h-5 w-5 text-yellow-500 mr-2 mt-0.5" />
<div>
<Typography.Text strong className="text-yellow-800">
CSV Structure Error
{t("bulkCreateUsers.csvStructureErrorTitle")}
</Typography.Text>
<Typography.Paragraph className="text-yellow-700 mt-1 mb-0">
{csvStructureError}
</Typography.Paragraph>
<Typography.Paragraph className="text-yellow-700 mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
{t("bulkCreateUsers.csvStructureErrorHint")}
</Typography.Paragraph>
</div>
</div>
@ -728,8 +716,8 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
? t("bulkCreateUsers.step3TitleResults")
: t("bulkCreateUsers.step3TitleReview")}
</h3>
</div>
@ -741,12 +729,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<Text className="text-red-600 font-medium">{parseError}</Text>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
<li>{t("bulkCreateUsers.parseErrorHint1")}</li>
<li>{t("bulkCreateUsers.parseErrorHint2")}</li>
<li>{t("bulkCreateUsers.parseErrorHint3")}</li>
</ul>
)}
</div>
@ -759,21 +744,28 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">Creation Summary</Text>
<Text className="text-lg font-medium mr-3">{t("bulkCreateUsers.creationSummary")}</Text>
<Text className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
{t("bulkCreateUsers.successfulCount", {
count: parsedData.filter((d) => d.status === "success").length,
})}
</Text>
{parsedData.some((d) => d.status === "failed") && (
<Text className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded">
{parsedData.filter((d) => d.status === "failed").length} Failed
{t("bulkCreateUsers.failedCount", {
count: parsedData.filter((d) => d.status === "failed").length,
})}
</Text>
)}
</div>
) : (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">User Preview</Text>
<Text className="text-lg font-medium mr-3">{t("bulkCreateUsers.userPreview")}</Text>
<Text className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
{t("bulkCreateUsers.validUsersCount", {
valid: parsedData.filter((d) => d.isValid).length,
total: parsedData.length,
})}
</Text>
</div>
)}
@ -787,14 +779,18 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setParseError(null);
}}
>
Back
{t("common.back")}
</Button>
<Button
type="primary"
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
{isProcessing
? t("bulkCreateUsers.creating")
: t("bulkCreateUsers.createUsersButton", {
count: parsedData.filter((d) => d.isValid).length,
})}
</Button>
</div>
)}
@ -807,11 +803,10 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
</div>
<div>
<Text className="font-medium text-blue-800">User creation complete</Text>
<Text className="font-medium text-blue-800">{t("bulkCreateUsers.creationComplete")}</Text>
<Text className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
<span className="font-medium">{t("bulkCreateUsers.nextStep")}</span>{" "}
{t("bulkCreateUsers.nextStepDesc")}
</Text>
</div>
</div>
@ -836,14 +831,18 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
}}
className="mr-3"
>
Back
{t("common.back")}
</Button>
<Button
type="primary"
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
{isProcessing
? t("bulkCreateUsers.creating")
: t("bulkCreateUsers.createUsersButton", {
count: parsedData.filter((d) => d.isValid).length,
})}
</Button>
</div>
)}
@ -857,10 +856,10 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
}}
className="mr-3"
>
Start New Bulk Import
{t("bulkCreateUsers.startNewImport")}
</Button>
<Button type="primary" onClick={downloadResults} icon={<DownloadOutlined />}>
Download User Credentials
{t("bulkCreateUsers.downloadCredentials")}
</Button>
</div>
)}

View file

@ -25,6 +25,7 @@ import { adminGlobalCacheActivity, cachingHealthCheckCall } from "./networking";
// Import the new component
import { CacheHealthTab } from "./cache_health";
import CacheSettings from "./cache_settings";
import { useTranslation } from "react-i18next";
const formatDateWithoutTZ = (date: Date | undefined) => {
if (!date) return undefined;
@ -97,6 +98,7 @@ const deepParse = (input: any) => {
};
const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole, userID, premiumUser }) => {
const { t } = useTranslation();
const [filteredData, setFilteredData] = useState<uiData[]>([]);
const [selectedApiKeys, setSelectedApiKeys] = useState<string[]>([]);
const [selectedModels, setSelectedModels] = useState<string[]>([]);
@ -160,8 +162,8 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
newData = newData.filter((item) => selectedModels.includes(item.model));
}
/*
Data looks like this
/*
Data looks like this
[{"api_key":"sk-test-mock-key-001","call_type":"acompletion","model":"llama3-8b-8192","total_rows":13,"cache_hit_true_rows":0},
{"api_key":"sk-test-mock-key-002","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0},
{"api_key":"sk-test-mock-key-123","call_type":"acompletion","model":"gpt-3.5-turbo","total_rows":19,"cache_hit_true_rows":0},
@ -239,7 +241,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
const runCachingHealthCheck = async () => {
try {
NotificationsManager.info("Running cache health check...");
NotificationsManager.info(t("cacheDashboard.runningHealthCheck"));
setHealthCheckResponse("");
const response = await cachingHealthCheckCall(accessToken !== null ? accessToken : "");
console.log("CACHING HEALTH CHECK RESPONSE", response);
@ -260,7 +262,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
errorData = { message: error.message };
}
} else {
errorData = { message: "Unknown error occurred" };
errorData = { message: t("cacheDashboard.unknownError") };
}
setHealthCheckResponse({ error: errorData });
}
@ -270,13 +272,13 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<TabGroup className="gap-2 p-8 h-full w-full mt-2 mb-8">
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">
<Tab>Cache Analytics</Tab>
<Tab>Cache Health</Tab>
<Tab>Cache Settings</Tab>
<Tab>{t("cacheDashboard.tabAnalytics")}</Tab>
<Tab>{t("cacheDashboard.tabHealth")}</Tab>
<Tab>{t("cacheDashboard.tabSettings")}</Tab>
</div>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
{lastRefreshed && <Text>{t("cacheDashboard.lastRefreshed", { time: lastRefreshed })}</Text>}
<Icon
icon={RefreshIcon} // Modify as necessary for correct icon name
variant="shadow"
@ -292,7 +294,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<Grid numItems={3} className="gap-4 mt-4">
<Col>
<MultiSelect
placeholder="Select Virtual Keys"
placeholder={t("cacheDashboard.selectVirtualKeys")}
value={selectedApiKeys}
onValueChange={setSelectedApiKeys}
>
@ -304,7 +306,11 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
</MultiSelect>
</Col>
<Col>
<MultiSelect placeholder="Select Models" value={selectedModels} onValueChange={setSelectedModels}>
<MultiSelect
placeholder={t("cacheDashboard.selectModels")}
value={selectedModels}
onValueChange={setSelectedModels}
>
{uniqueModels.map((model) => (
<MultiSelectItem key={model} value={model}>
{model}
@ -326,7 +332,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 mt-4">
<Card>
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
Cache Hit Ratio
{t("cacheDashboard.cacheHitRatio")}
</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
@ -336,7 +342,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
</Card>
<Card>
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
Cache Hits
{t("cacheDashboard.cacheHits")}
</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
@ -347,7 +353,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<Card>
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
Cached Tokens
{t("cacheDashboard.cachedTokens")}
</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
@ -357,9 +363,9 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
</Card>
</div>
<Subtitle className="mt-4">Cache Hits vs API Requests</Subtitle>
<Subtitle className="mt-4">{t("cacheDashboard.cacheHitsVsApiRequests")}</Subtitle>
<BarChart
title="Cache Hits vs API Requests"
title={t("cacheDashboard.cacheHitsVsApiRequests")}
data={filteredData}
stack={true}
index="name"
@ -369,7 +375,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
yAxisWidth={48}
/>
<Subtitle className="mt-4">Cached Completion Tokens vs Generated Completion Tokens</Subtitle>
<Subtitle className="mt-4">{t("cacheDashboard.cachedVsGeneratedTokens")}</Subtitle>
<BarChart
className="mt-6"
data={filteredData}

View file

@ -2,6 +2,7 @@ import React from "react";
import { Text, Button, TabGroup, TabList, Tab, TabPanel, TabPanels } from "@tremor/react";
import { CheckCircleIcon, XCircleIcon, ClipboardCopyIcon } from "@heroicons/react/outline";
import { ResponseTimeIndicator } from "./response_time_indicator";
import { useTranslation } from "react-i18next";
// Helper function to deep-parse a JSON string if possible
const deepParse = (input: any) => {
@ -72,6 +73,8 @@ interface ErrorDetails {
// Update HealthCheckDetails component to handle errors
const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
const { t } = useTranslation();
// Initialize with safe default values
let errorDetails: ErrorDetails | null = null;
let parsedLitellmParams: any = {};
@ -84,8 +87,8 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
typeof response.error.message === "string" ? JSON.parse(response.error.message) : response.error.message;
errorDetails = {
message: errorMessage?.message || "Unknown error",
traceback: errorMessage?.traceback || "No traceback available",
message: errorMessage?.message || t("cacheHealth.unknownError"),
traceback: errorMessage?.traceback || t("cacheHealth.noTraceback"),
litellm_params: errorMessage?.litellm_cache_params || {},
health_check_cache_params: errorMessage?.health_check_cache_params || {},
};
@ -95,8 +98,8 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
} catch (e) {
console.warn("Error parsing error details:", e);
errorDetails = {
message: String(response.error.message || "Unknown error"),
traceback: "Error parsing details",
message: String(response.error.message || t("cacheHealth.unknownError")),
traceback: t("cacheHealth.errorParsingDetails"),
litellm_params: {},
health_check_cache_params: {},
};
@ -154,8 +157,12 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
<div className="bg-white rounded-lg shadow">
<TabGroup>
<TabList className="border-b border-gray-200 px-4">
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">Summary</Tab>
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">Raw Response</Tab>
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">
{t("cacheHealth.tabSummary")}
</Tab>
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">
{t("cacheHealth.tabRawResponse")}
</Tab>
</TabList>
<TabPanels>
@ -170,7 +177,7 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
<Text
className={`text-sm font-medium ${response?.status === "healthy" ? "text-green-500" : "text-red-500"}`}
>
Cache Status: {response?.status || "unhealthy"}
{t("cacheHealth.cacheStatus", { status: response?.status || t("cacheHealth.statusUnhealthy") })}
</Text>
</div>
@ -181,23 +188,32 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
<>
<tr>
<td colSpan={2} className="pt-4 pb-2 font-semibold text-red-600">
Error Details
{t("cacheHealth.errorDetails")}
</td>
</tr>
<TableClickableErrorField label="Error Message" value={errorDetails.message} />
<TableClickableErrorField label="Traceback" value={errorDetails.traceback} />
<TableClickableErrorField label={t("cacheHealth.errorMessage")} value={errorDetails.message} />
<TableClickableErrorField label={t("cacheHealth.traceback")} value={errorDetails.traceback} />
</>
)}
{/* Always show cache details, regardless of error state */}
<tr>
<td colSpan={2} className="pt-4 pb-2 font-semibold">
Cache Details
{t("cacheHealth.cacheDetails")}
</td>
</tr>
<TableClickableErrorField label="Cache Configuration" value={String(parsedLitellmParams?.type)} />
<TableClickableErrorField label="Ping Response" value={String(response.ping_response)} />
<TableClickableErrorField label="Set Cache Response" value={response.set_cache_response || "N/A"} />
<TableClickableErrorField
label={t("cacheHealth.cacheConfiguration")}
value={String(parsedLitellmParams?.type)}
/>
<TableClickableErrorField
label={t("cacheHealth.pingResponse")}
value={String(response.ping_response)}
/>
<TableClickableErrorField
label={t("cacheHealth.setCacheResponse")}
value={response.set_cache_response || "N/A"}
/>
<TableClickableErrorField
label="litellm_settings.cache_params"
value={JSON.stringify(parsedLitellmParams, null, 2)}
@ -208,14 +224,29 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
<>
<tr>
<td colSpan={2} className="pt-4 pb-2 font-semibold">
Redis Details
{t("cacheHealth.redisDetails")}
</td>
</tr>
<TableClickableErrorField label="Redis Host" value={redisDetails.redis_host || "N/A"} />
<TableClickableErrorField label="Redis Port" value={redisDetails.redis_port || "N/A"} />
<TableClickableErrorField label="Redis Version" value={redisDetails.redis_version || "N/A"} />
<TableClickableErrorField label="Startup Nodes" value={redisDetails.startup_nodes || "N/A"} />
<TableClickableErrorField label="Namespace" value={redisDetails.namespace || "N/A"} />
<TableClickableErrorField
label={t("cacheHealth.redisHost")}
value={redisDetails.redis_host || "N/A"}
/>
<TableClickableErrorField
label={t("cacheHealth.redisPort")}
value={redisDetails.redis_port || "N/A"}
/>
<TableClickableErrorField
label={t("cacheHealth.redisVersion")}
value={redisDetails.redis_version || "N/A"}
/>
<TableClickableErrorField
label={t("cacheHealth.startupNodes")}
value={redisDetails.startup_nodes || "N/A"}
/>
<TableClickableErrorField
label={t("cacheHealth.namespace")}
value={redisDetails.namespace || "N/A"}
/>
</>
)}
</tbody>
@ -249,7 +280,7 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
// Then stringify with proper formatting
return JSON.stringify(prettyData, null, 2);
} catch (e) {
return "Error formatting JSON: " + (e as Error).message;
return t("cacheHealth.jsonFormatError", { error: (e as Error).message });
}
})()}
</pre>
@ -267,6 +298,7 @@ export const CacheHealthTab: React.FC<{
runCachingHealthCheck: () => void;
responseTimeMs?: number | null;
}> = ({ accessToken, healthCheckResponse, runCachingHealthCheck, responseTimeMs }) => {
const { t } = useTranslation();
const [localResponseTimeMs, setLocalResponseTimeMs] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
@ -287,7 +319,7 @@ export const CacheHealthTab: React.FC<{
disabled={isLoading}
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm px-4 py-2 rounded-md"
>
{isLoading ? "Running Health Check..." : "Run Health Check"}
{isLoading ? t("cacheHealth.runningHealthCheck") : t("cacheHealth.runHealthCheck")}
</Button>
<ResponseTimeIndicator responseTimeMs={localResponseTimeMs} />
</div>

View file

@ -1009,6 +1009,125 @@
"deleteSuccess": "Agent \"{{name}}\" deleted successfully",
"deleteFailed": "Failed to delete agent"
},
"bulkCreateUsers": {
"openButton": "+ Bulk Invite Users",
"modalTitle": "Bulk Invite Users",
"step1Title": "Download and fill the template",
"step1Desc": "Add multiple users at once by following these steps:",
"step1Item1": "Download our CSV template",
"step1Item2": "Add your users' information to the spreadsheet",
"step1Item3": "Save the file and upload it here",
"step1Item4": "After creation, download the results file containing the Virtual Keys for each user",
"templateColumnsTitle": "Template Column Names",
"colUserEmailDesc": "User's email address (required)",
"colUserRoleDesc": "User's role (one of: \"proxy_admin\", \"proxy_admin_viewer\", \"internal_user\", \"internal_user_viewer\")",
"colTeamsDesc": "Comma-separated team IDs (e.g., \"team-1,team-2\")",
"colMaxBudgetDesc": "Maximum budget as a number (e.g., \"100\")",
"colBudgetDurationDesc": "Budget reset period (e.g., \"30d\", \"1mo\")",
"colModelsDesc": "Comma-separated allowed models (e.g., \"gpt-3.5-turbo,gpt-4\")",
"downloadTemplateButton": "Download CSV Template",
"step2Title": "Upload your completed CSV",
"dropzone": "Drag and drop your CSV file here",
"dropzoneOr": "or",
"browseFiles": "Browse files",
"csvOnly": "Only CSV files (.csv) are supported",
"processing": "Processing...",
"csvStructureErrorTitle": "CSV Structure Error",
"csvStructureErrorHint": "Please download our template and ensure your CSV follows the required format.",
"step3TitleResults": "User Creation Results",
"step3TitleReview": "Review and create users",
"parseErrorHint1": "Check the table below for specific errors in each row",
"parseErrorHint2": "Common issues include invalid email formats, missing required fields, or incorrect role values",
"parseErrorHint3": "Fix these issues in your CSV file and upload again",
"creationSummary": "Creation Summary",
"successfulCount_one": "{{count}} Successful",
"successfulCount_other": "{{count}} Successful",
"failedCount_one": "{{count}} Failed",
"failedCount_other": "{{count}} Failed",
"userPreview": "User Preview",
"validUsersCount": "{{valid}} of {{total}} users valid",
"createUsersButton": "Create {{count}} Users",
"creating": "Creating...",
"creationComplete": "User creation complete",
"nextStep": "Next step:",
"nextStepDesc": "Download the credentials file containing Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests through LiteLLM.",
"startNewImport": "Start New Bulk Import",
"downloadCredentials": "Download User Credentials",
"colRow": "Row",
"colEmail": "Email",
"colRole": "Role",
"colTeams": "Teams",
"colBudget": "Budget",
"statusInvalid": "Invalid",
"statusPending": "Pending",
"statusFailed": "Failed",
"invitationLinkCopied": "Invitation link copied!",
"invalidFileType": "Invalid file type: {{name}}. Please upload a CSV file (.csv extension).",
"invalidFileTypeShort": "Invalid file type. Please upload a CSV file.",
"fileTooLarge": "File is too large ({{size}} MB). Please upload a CSV file smaller than 5MB.",
"csvEmpty": "The CSV file appears to be empty. Please upload a file with data.",
"csvHeaderOnly": "The CSV file only contains headers but no user data. Please add user data to your CSV.",
"csvNoHeaders": "The CSV file doesn't contain any column headers. Please make sure your CSV has headers.",
"csvMissingColumns": "Your CSV is missing these required columns: {{columns}}. Please add these columns to your CSV file.",
"rowTooFewColumns": "Row {{row}} has fewer columns than the header row. Please ensure all data is properly formatted.",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format (must contain @ and domain)",
"roleRequired": "Role is required",
"roleInvalid": "Invalid role \"{{role}}\". Must be one of: {{validRoles}}",
"budgetNotNumber": "Max budget \"{{budget}}\" must be a number",
"budgetMustBePositive": "Max budget must be greater than 0",
"budgetDurationInvalid": "Invalid budget duration format \"{{duration}}\". Use format like \"30d\", \"1mo\", \"2w\", \"6h\"",
"unknownTeams": "Unknown team(s): {{teams}}",
"noDataRows": "No valid data rows found in the CSV file. Please check your file format.",
"noValidUsers": "No valid users found in the CSV. Please check the errors below and fix your CSV file.",
"someRowsHaveErrors": "Found {{errorCount}} row(s) with errors out of {{total}} total rows. Please correct them before proceeding.",
"parsedSuccess_one": "Successfully parsed {{count}} user",
"parsedSuccess_other": "Successfully parsed {{count}} users",
"parseError": "Error parsing CSV: {{error}}",
"parseFileFailed": "Failed to parse CSV file: {{error}}",
"invitationLinkFailed": "User created but failed to generate invitation link",
"failedToCreateUser": "Failed to create user"
},
"cacheDashboard": {
"tabAnalytics": "Cache Analytics",
"tabHealth": "Cache Health",
"tabSettings": "Cache Settings",
"lastRefreshed": "Last Refreshed: {{time}}",
"selectVirtualKeys": "Select Virtual Keys",
"selectModels": "Select Models",
"cacheHitRatio": "Cache Hit Ratio",
"cacheHits": "Cache Hits",
"cachedTokens": "Cached Tokens",
"cacheHitsVsApiRequests": "Cache Hits vs API Requests",
"cachedVsGeneratedTokens": "Cached Completion Tokens vs Generated Completion Tokens",
"runningHealthCheck": "Running cache health check...",
"unknownError": "Unknown error occurred"
},
"cacheHealth": {
"runHealthCheck": "Run Health Check",
"runningHealthCheck": "Running Health Check...",
"tabSummary": "Summary",
"tabRawResponse": "Raw Response",
"cacheStatus": "Cache Status: {{status}}",
"statusUnhealthy": "unhealthy",
"errorDetails": "Error Details",
"errorMessage": "Error Message",
"traceback": "Traceback",
"cacheDetails": "Cache Details",
"cacheConfiguration": "Cache Configuration",
"pingResponse": "Ping Response",
"setCacheResponse": "Set Cache Response",
"redisDetails": "Redis Details",
"redisHost": "Redis Host",
"redisPort": "Redis Port",
"redisVersion": "Redis Version",
"startupNodes": "Startup Nodes",
"namespace": "Namespace",
"unknownError": "Unknown error",
"noTraceback": "No traceback available",
"errorParsingDetails": "Error parsing details",
"jsonFormatError": "Error formatting JSON: {{error}}"
},
"common": {
"save": "Save",
"cancel": "Cancel",

View file

@ -1009,6 +1009,125 @@
"deleteSuccess": "Agent「{{name}}」已成功删除",
"deleteFailed": "删除 Agent 失败"
},
"bulkCreateUsers": {
"openButton": "+ 批量邀请用户",
"modalTitle": "批量邀请用户",
"step1Title": "下载并填写模板",
"step1Desc": "按照以下步骤批量添加用户:",
"step1Item1": "下载 CSV 模板",
"step1Item2": "在表格中填写用户信息",
"step1Item3": "保存文件并上传",
"step1Item4": "创建完成后,下载包含每位用户虚拟密钥的结果文件",
"templateColumnsTitle": "模板列名说明",
"colUserEmailDesc": "用户邮箱地址(必填)",
"colUserRoleDesc": "用户角色(可选:\"proxy_admin\"、\"proxy_admin_viewer\"、\"internal_user\"、\"internal_user_viewer\")",
"colTeamsDesc": "逗号分隔的团队 ID(如:\"team-1,team-2\")",
"colMaxBudgetDesc": "最大预算(数字,如:\"100\")",
"colBudgetDurationDesc": "预算重置周期(如:\"30d\"、\"1mo\")",
"colModelsDesc": "逗号分隔的允许模型(如:\"gpt-3.5-turbo,gpt-4\")",
"downloadTemplateButton": "下载 CSV 模板",
"step2Title": "上传填写好的 CSV",
"dropzone": "将 CSV 文件拖拽到此处",
"dropzoneOr": "或",
"browseFiles": "浏览文件",
"csvOnly": "仅支持 CSV 文件(.csv 格式)",
"processing": "处理中...",
"csvStructureErrorTitle": "CSV 结构错误",
"csvStructureErrorHint": "请下载我们的模板,并确保您的 CSV 符合所需格式。",
"step3TitleResults": "用户创建结果",
"step3TitleReview": "预览并创建用户",
"parseErrorHint1": "查看下方表格,了解每行的具体错误",
"parseErrorHint2": "常见问题包括邮箱格式无效、缺少必填字段或角色值不正确",
"parseErrorHint3": "在 CSV 文件中修正这些问题后重新上传",
"creationSummary": "创建摘要",
"successfulCount_one": "{{count}} 个成功",
"successfulCount_other": "{{count}} 个成功",
"failedCount_one": "{{count}} 个失败",
"failedCount_other": "{{count}} 个失败",
"userPreview": "用户预览",
"validUsersCount": "{{valid}} / {{total}} 个用户有效",
"createUsersButton": "创建 {{count}} 个用户",
"creating": "创建中...",
"creationComplete": "用户创建完成",
"nextStep": "下一步:",
"nextStepDesc": "下载包含虚拟密钥和邀请链接的凭据文件。用户需要这些虚拟密钥通过 LiteLLM 发起 LLM 请求。",
"startNewImport": "开始新的批量导入",
"downloadCredentials": "下载用户凭据",
"colRow": "行号",
"colEmail": "邮箱",
"colRole": "角色",
"colTeams": "团队",
"colBudget": "预算",
"statusInvalid": "无效",
"statusPending": "待处理",
"statusFailed": "失败",
"invitationLinkCopied": "邀请链接已复制!",
"invalidFileType": "文件类型无效:{{name}}。请上传 CSV 文件(.csv 格式)。",
"invalidFileTypeShort": "文件类型无效,请上传 CSV 文件。",
"fileTooLarge": "文件过大({{size}} MB),请上传小于 5MB 的 CSV 文件。",
"csvEmpty": "CSV 文件为空,请上传包含数据的文件。",
"csvHeaderOnly": "CSV 文件仅包含表头,没有用户数据,请添加用户数据。",
"csvNoHeaders": "CSV 文件不包含列名,请确保 CSV 文件包含列名。",
"csvMissingColumns": "CSV 文件缺少以下必填列:{{columns}}。请添加这些列。",
"rowTooFewColumns": "第 {{row}} 行的列数少于表头行,请确保所有数据格式正确。",
"emailRequired": "邮箱为必填项",
"emailInvalid": "邮箱格式无效(必须包含 @ 和域名)",
"roleRequired": "角色为必填项",
"roleInvalid": "角色「{{role}}」无效,必须为以下之一:{{validRoles}}",
"budgetNotNumber": "最大预算「{{budget}}」必须为数字",
"budgetMustBePositive": "最大预算必须大于 0",
"budgetDurationInvalid": "预算周期格式「{{duration}}」无效,请使用如「30d」、「1mo」、「2w」、「6h」等格式",
"unknownTeams": "未知团队:{{teams}}",
"noDataRows": "CSV 文件中未找到有效数据行,请检查文件格式。",
"noValidUsers": "CSV 中未找到有效用户,请查看下方错误并修正 CSV 文件。",
"someRowsHaveErrors": "共 {{total}} 行中有 {{errorCount}} 行存在错误,请修正后再继续。",
"parsedSuccess_one": "成功解析 {{count}} 个用户",
"parsedSuccess_other": "成功解析 {{count}} 个用户",
"parseError": "解析 CSV 时出错:{{error}}",
"parseFileFailed": "解析 CSV 文件失败:{{error}}",
"invitationLinkFailed": "用户已创建,但生成邀请链接失败",
"failedToCreateUser": "创建用户失败"
},
"cacheDashboard": {
"tabAnalytics": "缓存分析",
"tabHealth": "缓存健康",
"tabSettings": "缓存设置",
"lastRefreshed": "最后刷新:{{time}}",
"selectVirtualKeys": "选择虚拟密钥",
"selectModels": "选择模型",
"cacheHitRatio": "缓存命中率",
"cacheHits": "缓存命中数",
"cachedTokens": "已缓存 Token 数",
"cacheHitsVsApiRequests": "缓存命中 vs API 请求",
"cachedVsGeneratedTokens": "已缓存 Token vs 生成 Token",
"runningHealthCheck": "正在运行缓存健康检查...",
"unknownError": "发生未知错误"
},
"cacheHealth": {
"runHealthCheck": "运行健康检查",
"runningHealthCheck": "健康检查中...",
"tabSummary": "摘要",
"tabRawResponse": "原始响应",
"cacheStatus": "缓存状态:{{status}}",
"statusUnhealthy": "不健康",
"errorDetails": "错误详情",
"errorMessage": "错误信息",
"traceback": "Traceback",
"cacheDetails": "缓存详情",
"cacheConfiguration": "缓存配置",
"pingResponse": "Ping 响应",
"setCacheResponse": "写缓存响应",
"redisDetails": "Redis 详情",
"redisHost": "Redis 主机",
"redisPort": "Redis 端口",
"redisVersion": "Redis 版本",
"startupNodes": "启动节点",
"namespace": "命名空间",
"unknownError": "未知错误",
"noTraceback": "无 Traceback 信息",
"errorParsingDetails": "解析详情时出错",
"jsonFormatError": "JSON 格式化出错:{{error}}"
},
"common": {
"save": "保存",
"cancel": "取消",