fix(i18n): add Chinese i18n support to remaining dashboard components

- Add translations for playground page endpoints and UI elements
- Localize ProxyRegistryManager component text
- Add Chinese support for CursorAuthModal, OAuthModal, PricingModal
- Localize ProxyConfigModal and RequestLoggerV2 components
- Update both English and Chinese message files with new translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
clousky
2026-04-15 21:11:18 +08:00
parent cba6524926
commit f07c7aea2f
8 changed files with 557 additions and 269 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card, Button, Select, Badge } from "@/shared/components";
import { ALIAS_TO_ID } from "@/shared/constants/providers";
import { pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail";
@@ -31,18 +32,7 @@ interface ConnectionOption {
authType: string;
}
const ENDPOINT_OPTIONS = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses" },
{ value: "images", label: "Image Generation" },
{ value: "embeddings", label: "Embeddings" },
{ value: "speech", label: "Text to Speech" },
{ value: "transcription", label: "Audio Transcription" },
{ value: "video", label: "Video Generation" },
{ value: "music", label: "Music Generation" },
{ value: "rerank", label: "Rerank" },
{ value: "search", label: "Web Search" },
];
// Endpoint options will be generated dynamically with translations
const DEFAULT_BODIES: Record<string, object> = {
chat: {
@@ -154,13 +144,14 @@ async function fileToBase64(file: File): Promise<string> {
/** Render image results from OpenAI-compatible format */
function ImageResultsInline({ data }: { data: any }) {
const t = useTranslations("playground");
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
data?.data || [];
if (images.length === 0) return null;
return (
<div className="p-4 space-y-3">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
{images.length} image{images.length > 1 ? "s" : ""} generated
{t("imagesGenerated", { count: images.length })}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{images.map((img, i) => {
@@ -171,7 +162,7 @@ function ImageResultsInline({ data }: { data: any }) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={img.revised_prompt || `Generated image ${i + 1}`}
alt={img.revised_prompt || t("generatedImage", { index: i + 1 })}
className="w-full"
/>
<a
@@ -180,7 +171,7 @@ function ImageResultsInline({ data }: { data: any }) {
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Save
{t("save")}
</a>
</div>
);
@@ -191,6 +182,22 @@ function ImageResultsInline({ data }: { data: any }) {
}
export default function PlaygroundPage() {
const t = useTranslations("playground");
// Get translated endpoint options
const getEndpointOptions = () => [
{ value: "chat", label: t("endpointOptions.chat") },
{ value: "responses", label: t("endpointOptions.responses") },
{ value: "images", label: t("endpointOptions.images") },
{ value: "embeddings", label: t("endpointOptions.embeddings") },
{ value: "speech", label: t("endpointOptions.speech") },
{ value: "transcription", label: t("endpointOptions.transcription") },
{ value: "video", label: t("endpointOptions.video") },
{ value: "music", label: t("endpointOptions.music") },
{ value: "rerank", label: t("endpointOptions.rerank") },
{ value: "search", label: t("endpointOptions.search") },
];
const [models, setModels] = useState<ModelInfo[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
const [allConnections, setAllConnections] = useState<ConnectionOption[]>([]);
@@ -472,11 +479,8 @@ export default function PlaygroundPage() {
science
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Model Playground</p>
<p>
Test any model directly from the dashboard. Pick a provider, model, and endpoint type,
then send a request to see the raw response.
</p>
<p className="font-medium text-text-main mb-0.5">{t("title")}</p>
<p>{t("description")}</p>
</div>
</div>
@@ -486,12 +490,12 @@ export default function PlaygroundPage() {
{/* Endpoint — always first */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Endpoint
{t("endpoint")}
</label>
<Select
value={selectedEndpoint}
onChange={(e: any) => handleEndpointChange(e.target.value)}
options={ENDPOINT_OPTIONS}
options={getEndpointOptions()}
className="w-full"
/>
</div>
@@ -500,7 +504,7 @@ export default function PlaygroundPage() {
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
{t("provider")}
</label>
<Select
value={selectedProvider}
@@ -515,7 +519,7 @@ export default function PlaygroundPage() {
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
{t("model")}
</label>
<Select
value={selectedModel}
@@ -530,7 +534,7 @@ export default function PlaygroundPage() {
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Account / Key
{t("accountKey")}
</label>
<Select
value={selectedConnection}
@@ -540,8 +544,8 @@ export default function PlaygroundPage() {
value: "",
label:
providerConnections.length > 0
? `Auto (${providerConnections.length} accounts)`
: "No accounts",
? t("autoAccounts", { count: providerConnections.length })
: t("noAccounts"),
},
...providerConnections.map((c) => ({
value: c.id,
@@ -558,7 +562,7 @@ export default function PlaygroundPage() {
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
{t("cancel")}
</Button>
) : (
<Button
@@ -569,7 +573,7 @@ export default function PlaygroundPage() {
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
{t("send")}
</Button>
)}
</div>
@@ -591,16 +595,16 @@ export default function PlaygroundPage() {
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
{isTranscriptionEndpoint ? t("audioFile") : t("attachImages")}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
multipart/form-data
{t("multipartFormData")}
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
up to 4 images
{t("upToImages")}
</Badge>
)}
</div>
@@ -623,7 +627,7 @@ export default function PlaygroundPage() {
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac)
{t("selectAudioFile")}
</p>
)}
</div>
@@ -664,7 +668,7 @@ export default function PlaygroundPage() {
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
Clear all
{t("clearAll")}
</button>
</div>
)}
@@ -684,7 +688,7 @@ export default function PlaygroundPage() {
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<h3 className="text-sm font-semibold text-text-main">{t("request")}</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
@@ -693,7 +697,7 @@ export default function PlaygroundPage() {
<button
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -704,7 +708,7 @@ export default function PlaygroundPage() {
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
@@ -715,8 +719,7 @@ export default function PlaygroundPage() {
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
Transcription uses multipart/form-data. Upload the audio file above JSON below
controls extra params (model, language).
{t("transcriptionHint")}
</p>
)}
<div className="border border-border rounded-lg overflow-hidden">
@@ -748,7 +751,7 @@ export default function PlaygroundPage() {
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
<h3 className="text-sm font-semibold text-text-main">{t("response")}</h3>
{responseStatus !== null && (
<Badge
variant={
@@ -772,7 +775,7 @@ export default function PlaygroundPage() {
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -788,7 +791,7 @@ export default function PlaygroundPage() {
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download audio
{t("downloadAudio")}
</a>
</div>
) : imageData ? (
@@ -796,7 +799,7 @@ export default function PlaygroundPage() {
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
Transcription
{t("transcription")}
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
@@ -806,7 +809,7 @@ export default function PlaygroundPage() {
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
Copy text
{t("copyText")}
</button>
</div>
) : (

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Card, Modal } from "@/shared/components";
type ProxyItem = {
@@ -51,6 +52,7 @@ const EMPTY_FORM = {
};
export default function ProxyRegistryManager() {
const t = useTranslations("proxyRegistry");
const [items, setItems] = useState<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -124,7 +126,7 @@ export default function ProxyRegistryManager() {
const res = await fetch("/api/settings/proxies");
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to load proxy registry");
setError(data?.error?.message || t("errorLoadFailed"));
setItems([]);
return;
}
@@ -134,7 +136,7 @@ export default function ProxyRegistryManager() {
void loadHealth();
void loadAllUsage(ids);
} catch (e: any) {
setError(e?.message || "Failed to load proxy registry");
setError(e?.message || t("errorLoadFailed"));
setItems([]);
} finally {
setLoading(false);
@@ -221,7 +223,7 @@ export default function ProxyRegistryManager() {
if (!res.ok) {
setTestById((prev) => ({
...prev,
[item.id]: { success: false, error: data?.error?.message || "Test failed" },
[item.id]: { success: false, error: data?.error?.message || t("failed") },
}));
return;
}
@@ -235,7 +237,7 @@ export default function ProxyRegistryManager() {
const handleSave = async () => {
if (!form.name.trim() || !form.host.trim()) {
setError("Name and host are required");
setError(t("errorNameHostRequired"));
return;
}
@@ -270,7 +272,7 @@ export default function ProxyRegistryManager() {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to save proxy");
setError(data?.error?.message || t("errorSaveFailed"));
return;
}
@@ -278,7 +280,7 @@ export default function ProxyRegistryManager() {
setForm(EMPTY_FORM);
await load();
} catch (e: any) {
setError(e?.message || "Failed to save proxy");
setError(e?.message || t("errorSaveFailed"));
} finally {
setSaving(false);
}
@@ -298,9 +300,7 @@ export default function ProxyRegistryManager() {
const payload = await res.json().catch(() => ({}));
const inUse = res.status === 409;
if (inUse) {
const ok = window.confirm(
"This proxy is still assigned. Force delete and remove all assignments?"
);
const ok = window.confirm(t("errorForceDeleteConfirm"));
if (!ok) return;
const forceRes = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}&force=1`, {
@@ -309,7 +309,7 @@ export default function ProxyRegistryManager() {
if (!forceRes.ok) {
const forcePayload = await forceRes.json().catch(() => ({}));
setError(forcePayload?.error?.message || "Failed to force delete proxy");
setError(forcePayload?.error?.message || t("errorDeleteFailed"));
return;
}
@@ -317,9 +317,9 @@ export default function ProxyRegistryManager() {
return;
}
setError(payload?.error?.message || "Failed to delete proxy");
setError(payload?.error?.message || t("errorDeleteFailed"));
} catch (e: any) {
setError(e?.message || "Failed to delete proxy");
setError(e?.message || t("errorDeleteFailed"));
}
};
@@ -334,12 +334,12 @@ export default function ProxyRegistryManager() {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to migrate legacy proxy config");
setError(data?.error?.message || t("errorMigrateFailed"));
return;
}
await load();
} catch (e: any) {
setError(e?.message || "Failed to migrate legacy proxy config");
setError(e?.message || t("errorMigrateFailed"));
} finally {
setMigrating(false);
}
@@ -368,7 +368,7 @@ export default function ProxyRegistryManager() {
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error?.message || "Failed to run bulk assignment");
setError(payload?.error?.message || t("errorBulkFailed"));
return;
}
@@ -376,7 +376,7 @@ export default function ProxyRegistryManager() {
setBulkScopeIds("");
await load();
} catch (e: any) {
setError(e?.message || "Failed to run bulk assignment");
setError(e?.message || t("errorBulkFailed"));
} finally {
setBulkSaving(false);
}
@@ -387,8 +387,8 @@ export default function ProxyRegistryManager() {
<Card className="p-6">
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-lg font-semibold">Proxy Registry</h3>
<p className="text-sm text-text-muted">Store reusable proxies and track assignments.</p>
<h3 className="text-lg font-semibold">{t("title")}</h3>
<p className="text-sm text-text-muted">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<Button
@@ -399,7 +399,7 @@ export default function ProxyRegistryManager() {
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
Import Legacy
{t("importLegacy")}
</Button>
<Button
size="sm"
@@ -408,7 +408,7 @@ export default function ProxyRegistryManager() {
onClick={() => setBulkOpen(true)}
data-testid="proxy-registry-open-bulk"
>
Bulk Assign
{t("bulkAssign")}
</Button>
<Button
size="sm"
@@ -416,7 +416,7 @@ export default function ProxyRegistryManager() {
onClick={openCreate}
data-testid="proxy-registry-open-create"
>
Add Proxy
{t("addProxy")}
</Button>
</div>
</div>
@@ -428,20 +428,20 @@ export default function ProxyRegistryManager() {
)}
{loading ? (
<div className="text-sm text-text-muted">Loading proxies...</div>
<div className="text-sm text-text-muted">{t("loading")}</div>
) : items.length === 0 ? (
<div className="text-sm text-text-muted">No saved proxies yet.</div>
<div className="text-sm text-text-muted">{t("noProxies")}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-text-muted border-b border-border">
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">Endpoint</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Health (24h)</th>
<th className="py-2 pr-3">Usage</th>
<th className="py-2">Actions</th>
<th className="py-2 pr-3">{t("tableName")}</th>
<th className="py-2 pr-3">{t("tableEndpoint")}</th>
<th className="py-2 pr-3">{t("tableStatus")}</th>
<th className="py-2 pr-3">{t("tableHealth")}</th>
<th className="py-2 pr-3">{t("tableUsage")}</th>
<th className="py-2">{t("tableActions")}</th>
</tr>
</thead>
<tbody>
@@ -483,8 +483,10 @@ export default function ProxyRegistryManager() {
)
) : health ? (
<>
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
<span>{t("successRate", { rate: health.successRate ?? 0 })}</span>
<span>
{t("avgLatency", { latency: health.avgLatencyMs ?? "-" })}
</span>
</>
) : (
<span></span>
@@ -493,8 +495,8 @@ export default function ProxyRegistryManager() {
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{usageById[item.id] != null
? `${usageById[item.id].count} assignment(s)`
: "—"}
? t("assignmentsCount", { count: usageById[item.id].count })
: t("noData")}
</td>
<td className="py-2">
<div className="flex items-center gap-1">
@@ -505,7 +507,7 @@ export default function ProxyRegistryManager() {
onClick={() => void handleTestProxy(item)}
loading={testingId === item.id}
>
Test
{t("test")}
</Button>
<Button
size="sm"
@@ -513,7 +515,7 @@ export default function ProxyRegistryManager() {
icon="edit"
onClick={() => openEdit(item)}
>
Edit
{t("edit")}
</Button>
<Button
size="sm"
@@ -522,7 +524,7 @@ export default function ProxyRegistryManager() {
onClick={() => void handleDelete(item.id)}
className="!text-red-400"
>
Delete
{t("delete")}
</Button>
</div>
</td>
@@ -540,13 +542,13 @@ export default function ProxyRegistryManager() {
onClose={() => {
if (!saving) setModalOpen(false);
}}
title={editingId ? "Edit Proxy" : "Create Proxy"}
title={editingId ? t("modalEditTitle") : t("modalCreateTitle")}
maxWidth="lg"
>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted mb-1 block">Name</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelName")}</label>
<input
data-testid="proxy-registry-name-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"

View File

@@ -1230,7 +1230,7 @@
"enableCloud": "启用云端",
"modelsAcrossEndpoints": "{endpoints} 个端点共提供 {models} 个模型",
"chatDesc": "支持所有提供商的流式与非流式聊天",
"embeddings": "嵌入向量",
"embeddings": "Embeddings",
"embeddingsDesc": "用于搜索和 RAG 流程的文本向量",
"imageGeneration": "图像生成",
"imageDesc": "根据文本提示生成图像",
@@ -1556,7 +1556,7 @@
"rateLimit": "速率限制",
"remaining": "剩余",
"requestsPerMinute": "请求/分钟",
"tokensPerMinute": "令牌/分钟",
"tokensPerMinute": "Tokens/分钟",
"dailyLimit": "每日限额"
},
"logs": {
@@ -1922,6 +1922,10 @@
"email": "电子邮件",
"healthCheckMinutes": "健康检查(分钟)",
"healthCheckHint": "主动令牌刷新间隔。 0 = 禁用。",
"selectAllModels": "全选",
"deselectAllModels": "取消全选",
"modelsActiveCount": "{active}/{total} 已启用",
"noModelsMatch": "没有匹配 \"{filter}\" 的模型",
"groupLabel": "环境",
"groupPlaceholder": "例如eKaizen、Personal",
"failedTestConnection": "测试连接失败",
@@ -3389,5 +3393,258 @@
"timeoutDesc": "等待响应的最长时间",
"networkAccess": "网络访问",
"networkAccessDesc": "允许发起出站网络请求"
},
"proxyConfigModal": {
"levelGlobal": "全局",
"levelProvider": "供应商",
"levelCombo": "组合",
"levelKey": "密钥",
"levelDirect": "直接(无代理)",
"titleGlobal": "全局代理配置",
"titleLevel": "{level} 代理 — {label}",
"loading": "正在加载代理配置...",
"inheritingFrom": "继承自",
"source": "来源",
"savedProxy": "已保存代理",
"custom": "自定义",
"selectSavedProxyPlaceholder": "选择已保存的代理...",
"proxyType": "代理类型",
"host": "主机",
"hostPlaceholder": "1.2.3.4 或 proxy.example.com",
"port": "端口",
"authOptional": "认证(可选)",
"username": "用户名",
"usernamePlaceholder": "用户名",
"password": "密码",
"passwordPlaceholder": "密码",
"connected": "已连接",
"ip": "IP:",
"connectionFailed": "连接失败",
"testConnection": "测试连接",
"clear": "清除",
"cancel": "取消",
"save": "保存",
"errorSelectSavedProxy": "请先选择已保存的代理。",
"errorSelectProxyFirst": "请先选择代理。",
"errorProxyNotFound": "所选代理未找到。",
"errorClearSavedProxy": "清除已保存代理失败",
"errorSaveProxy": "保存代理配置失败",
"errorClearProxy": "清除代理配置失败",
"errorSocks5Hidden": "SOCKS5 已配置但已隐藏,因为 NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false。"
},
"oauthModal": {
"title": "连接 {providerName}",
"waiting": "等待授权",
"completeAuthInPopup": "在弹出窗口中完成授权。",
"popupClosedHint": "如果弹出窗口关闭而没有重定向回来(例如 Qoder此对话框将自动切换到手动 URL 输入模式。",
"popupBlocked": "弹出窗口被阻止?手动输入 URL",
"deviceCodeVisitUrl": "访问下面的 URL 并输入代码:",
"deviceCodeVerificationUrl": "验证 URL",
"deviceCodeYourCode": "您的代码",
"deviceCodeWaiting": "等待授权...",
"googleOAuthWarning": "远程访问 + Google OAuth默认凭据仅接受重定向到 <code>localhost</code>。授权后,您的浏览器将尝试打开 <code>localhost</code> — 复制该完整 URL 并粘贴到下方。要完全远程使用而无需此手动步骤,<a>配置您自己的 OAuth 凭据</a>。",
"remoteAccessInfo": "远程访问:由于您是远程访问 OmniRoute授权后您会看到一个错误页面localhost 未找到)。这是正常的 — 只需从浏览器地址栏复制完整 URL 并粘贴到下方。",
"step1OpenUrl": "步骤 1在浏览器中打开此 URL",
"copy": "复制",
"step2PasteCallback": "步骤 2在此处粘贴回调 URL 或授权代码",
"step2Hint": "授权后,粘贴完整的回调 URL。对于 Claude Code 和 Cline您也可以直接粘贴身份验证代码例如 <code>code#state</code>。",
"connect": "连接",
"cancel": "取消",
"success": "连接成功!",
"successMessage": "您的 {providerName} 账户已连接。",
"done": "完成",
"error": "连接失败",
"tryAgain": "重试"
},
"cursorAuthModal": {
"title": "连接 Cursor IDE",
"autoDetecting": "自动检测令牌中...",
"readingFromCursor": "正在从 Cursor IDE 或 cursor-agent 读取",
"tokensAutoDetected": "已成功从 Cursor IDE 自动检测到令牌!",
"cursorNotDetected": "未检测到 Cursor IDE。请手动粘贴您的令牌。",
"accessToken": "访问令牌",
"required": "*",
"accessTokenPlaceholder": "访问令牌将自动填充...",
"machineId": "机器 ID",
"optional": "(可选)",
"machineIdPlaceholder": "机器 ID 将自动填充...",
"importing": "正在导入...",
"importToken": "导入令牌",
"cancel": "取消",
"errorAutoDetect": "无法自动检测令牌",
"errorAutoDetectFailed": "自动检测令牌失败",
"errorEnterToken": "请输入访问令牌",
"errorImportFailed": "导入失败"
},
"pricingModal": {
"title": "定价配置",
"loading": "正在加载定价数据...",
"pricingRatesFormat": "定价费率格式",
"ratesDescription": "所有费率均为 <strong>每百万令牌美元</strong>$/1M 令牌)。示例:输入费率为 2.50 表示每 1,000,000 个输入令牌收费 2.50 美元。",
"model": "模型",
"input": "输入",
"output": "输出",
"cached": "缓存",
"reasoning": "推理",
"cacheCreation": "缓存创建",
"noPricingData": "无定价数据可用",
"resetToDefaults": "重置为默认值",
"cancel": "取消",
"saving": "正在保存...",
"saveChanges": "保存更改",
"resetConfirm": "将所有定价重置为默认值?此操作无法撤销。",
"errorSaveFailed": "保存定价失败",
"errorResetFailed": "重置定价失败"
},
"proxyRegistry": {
"title": "代理注册表",
"description": "存储可重用的代理并跟踪分配。",
"importLegacy": "导入旧版",
"bulkAssign": "批量分配",
"addProxy": "添加代理",
"loading": "正在加载代理...",
"noProxies": "暂无保存的代理。",
"tableName": "名称",
"tableEndpoint": "端点",
"tableStatus": "状态",
"tableHealth": "健康状态24小时",
"tableUsage": "使用情况",
"tableActions": "操作",
"test": "测试",
"edit": "编辑",
"delete": "删除",
"modalCreateTitle": "创建代理",
"modalEditTitle": "编辑代理",
"labelName": "名称",
"labelType": "类型",
"labelHost": "主机",
"labelPort": "端口",
"labelUsername": "用户名",
"labelPassword": "密码",
"labelRegion": "区域",
"labelStatus": "状态",
"labelNotes": "备注",
"usernamePlaceholderEdit": "留空以保留当前用户名",
"passwordPlaceholderEdit": "留空以保留当前密码",
"statusActive": "活跃",
"statusInactive": "非活跃",
"cancel": "取消",
"save": "保存",
"bulkModalTitle": "批量代理分配",
"bulkLabelScope": "范围",
"bulkLabelProxy": "代理",
"bulkClearAssignment": "(清除分配)",
"bulkLabelScopeIds": "范围 ID逗号或换行符分隔",
"bulkScopeIdsPlaceholder": "provider-openai,provider-anthropic",
"bulkApply": "应用",
"errorLoadFailed": "加载代理注册表失败",
"errorNameHostRequired": "名称和主机为必填项",
"errorSaveFailed": "保存代理失败",
"errorDeleteFailed": "删除代理失败",
"errorForceDeleteConfirm": "此代理仍在分配中。强制删除并移除所有分配?",
"errorMigrateFailed": "迁移旧版代理配置失败",
"errorBulkFailed": "执行批量分配失败",
"success": "✓",
"failure": "✗",
"failed": "失败",
"successRate": "{rate}% 成功",
"avgLatency": "{latency} 毫秒平均",
"assignmentsCount": "{count} 个分配",
"noData": "—",
"testSuccess": "✓ {ip}",
"testLatency": "{latency}毫秒",
"testFailure": "✗ {error}"
},
"playground": {
"title": "模型演练场",
"description": "直接从仪表板测试任何模型。选择提供商、模型和端点类型,然后发送请求以查看原始响应。",
"endpoint": "端点",
"provider": "提供商",
"model": "模型",
"accountKey": "账户 / 密钥",
"autoAccounts": "自动({count} 个账户)",
"noAccounts": "无账户",
"send": "发送",
"cancel": "取消",
"audioFile": "音频文件",
"attachImages": "附加图片(视觉)",
"multipartFormData": "multipart/form-data",
"upToImages": "最多 4 张图片",
"selectAudioFile": "选择音频文件进行转录mp3、wav、m4a、ogg、flac…",
"clearAll": "清除全部",
"request": "请求",
"response": "响应",
"transcription": "转录",
"copy": "复制",
"resetToDefault": "重置为默认",
"downloadAudio": "下载音频",
"copyText": "复制文本",
"transcriptionHint": "转录使用 multipart/form-data。上传上面的音频文件 — 下面的 JSON 控制额外参数(模型、语言)。",
"imagesGenerated": "生成了 {count} 张图片",
"generatedImage": "生成图片 {index}",
"save": "保存",
"endpointOptions": {
"chat": "聊天补全",
"responses": "响应",
"images": "图片生成",
"embeddings": "Embeddings",
"speech": "文本转语音",
"transcription": "音频转录",
"video": "视频生成",
"music": "音乐生成",
"rerank": "Rerank",
"search": "网页搜索"
}
},
"requestLogger": {
"recording": "正在录制",
"paused": "已暂停",
"pipelineLogsOn": "管道日志开启",
"pipelineLogsOff": "管道日志关闭",
"updatingPipelineLogs": "正在更新管道日志...",
"searchPlaceholder": "搜索模型、提供商、账户、API密钥、组合...",
"allProviders": "所有提供商",
"allModels": "所有模型",
"allAccounts": "所有账户",
"allApiKeys": "所有API密钥",
"total": "总计",
"ok": "成功",
"err": "错误",
"combo": "组合",
"keys": "密钥",
"shown": "显示",
"sortNewest": "最新",
"sortOldest": "最早",
"sortTokensDesc": "令牌 ↓",
"sortTokensAsc": "令牌 ↑",
"sortDurationDesc": "时长 ↓",
"sortDurationAsc": "时长 ↑",
"sortStatusDesc": "状态 ↓",
"sortStatusAsc": "状态 ↑",
"sortModelAsc": "模型 A-Z",
"sortModelDesc": "模型 Z-A",
"statusFilters": {
"all": "全部",
"error": "错误",
"success": "成功",
"combo": "组合"
},
"columns": {
"status": "状态",
"model": "模型",
"requested": "请求",
"provider": "提供商",
"protocol": "请求协议",
"account": "账户",
"apiKey": "API密钥",
"combo": "组合",
"tokens": "Tokens",
"duration": "时长",
"time": "时间"
},
"loadingLogs": "正在加载日志...",
"noLogs": "暂无日志记录。进行一些API调用以在此处查看它们。",
"noMatchingLogs": "没有匹配当前筛选器的日志。",
"callLogsInfo": "调用日志也保存为JSON文件到{dataDir},并根据{retentionDays}和{maxEntries}进行轮换。"
}
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
import Input from "./Input";
@@ -11,6 +12,7 @@ import Input from "./Input";
* Auto-detect and import token from Cursor IDE's local SQLite database
*/
export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
const t = useTranslations("cursorAuthModal");
const [accessToken, setAccessToken] = useState("");
const [machineId, setMachineId] = useState("");
const [error, setError] = useState(null);
@@ -36,10 +38,10 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
setMachineId(data.machineId || "");
setAutoDetected(true);
} else {
setError(data.error || "Could not auto-detect tokens");
setError(data.error || t("errorAutoDetect"));
}
} catch (err) {
setError("Failed to auto-detect tokens");
setError(t("errorAutoDetectFailed"));
} finally {
setAutoDetecting(false);
}
@@ -50,7 +52,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
const handleImportToken = async () => {
if (!accessToken.trim()) {
setError("Please enter an access token");
setError(t("errorEnterToken"));
return;
}
@@ -70,7 +72,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
throw new Error(data.error || t("errorImportFailed"));
}
// Success - close modal and trigger refresh
@@ -84,7 +86,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
};
return (
<Modal isOpen={isOpen} title="Connect Cursor IDE" onClose={onClose}>
<Modal isOpen={isOpen} title={t("title")} onClose={onClose}>
<div className="flex flex-col gap-4">
{/* Auto-detecting state */}
{autoDetecting && (
@@ -94,8 +96,8 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Auto-detecting tokens...</h3>
<p className="text-sm text-text-muted">Reading from Cursor IDE or cursor-agent</p>
<h3 className="text-lg font-semibold mb-2">{t("autoDetecting")}</h3>
<p className="text-sm text-text-muted">{t("readingFromCursor")}</p>
</div>
)}
@@ -110,7 +112,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
check_circle
</span>
<p className="text-sm text-green-800 dark:text-green-200">
Tokens auto-detected from Cursor IDE successfully!
{t("tokensAutoDetected")}
</p>
</div>
</div>
@@ -124,7 +126,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
info
</span>
<p className="text-sm text-blue-800 dark:text-blue-200">
Cursor IDE not detected. Please paste your tokens manually.
{t("cursorNotDetected")}
</p>
</div>
</div>
@@ -133,12 +135,12 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
{/* Access Token Input */}
<div>
<label className="block text-sm font-medium mb-2">
Access Token <span className="text-red-500">*</span>
{t("accessToken")} <span className="text-red-500">{t("required")}</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="Access token will be auto-filled..."
placeholder={t("accessTokenPlaceholder")}
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
@@ -147,12 +149,12 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
{/* Machine ID Input (optional — not needed for cursor-agent imports) */}
<div>
<label className="block text-sm font-medium mb-2">
Machine ID <span className="text-text-muted text-xs">(optional)</span>
{t("machineId")} <span className="text-text-muted text-xs">{t("optional")}</span>
</label>
<Input
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
placeholder="Machine ID will be auto-filled..."
placeholder={t("machineIdPlaceholder")}
className="font-mono text-sm"
/>
</div>
@@ -171,10 +173,10 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
fullWidth
disabled={importing || !accessToken.trim()}
>
{importing ? "Importing..." : "Import Token"}
{importing ? t("importing") : t("importToken")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
import Input from "./Input";
@@ -31,6 +32,7 @@ export default function OAuthModal({
onClose,
idcConfig,
}: OAuthModalProps) {
const t = useTranslations("oauthModal");
const [step, setStep] = useState("waiting"); // waiting | input | success | error
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
@@ -569,7 +571,12 @@ export default function OAuthModal({
if (!provider || !providerInfo) return null;
return (
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={onClose} size="lg">
<Modal
isOpen={isOpen}
title={t("title", { providerName: providerInfo.name })}
onClose={onClose}
size="lg"
>
<div className="flex flex-col gap-4">
{/* Waiting Step (Localhost - popup mode) */}
{step === "waiting" && !isDeviceCode && (
@@ -579,16 +586,11 @@ export default function OAuthModal({
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Waiting for Authorization</h3>
<p className="text-sm text-text-muted mb-2">
Complete the authorization in the popup window.
</p>
<p className="text-xs text-text-muted mb-4 opacity-70">
If the popup closes without redirecting back (e.g. Qoder), this dialog will
automatically switch to manual URL input mode.
</p>
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
<Button variant="ghost" onClick={() => setStep("input")}>
Popup blocked? Enter URL manually
{t("popupBlocked")}
</Button>
</div>
)}
@@ -597,11 +599,9 @@ export default function OAuthModal({
{step === "waiting" && isDeviceCode && deviceData && (
<>
<div className="text-center py-4">
<p className="text-sm text-text-muted mb-4">
Visit the URL below and enter the code:
</p>
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
<div className="bg-sidebar p-4 rounded-lg mb-4">
<p className="text-xs text-text-muted mb-1">Verification URL</p>
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm break-all">{deviceData.verification_uri}</code>
<Button
@@ -613,7 +613,7 @@ export default function OAuthModal({
</div>
</div>
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">Your Code</p>
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-mono font-bold text-primary">
{deviceData.user_code}
@@ -630,7 +630,7 @@ export default function OAuthModal({
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
Waiting for authorization...
{t("deviceCodeWaiting")}
</div>
)}
</>
@@ -646,33 +646,27 @@ export default function OAuthModal({
<span className="material-symbols-outlined text-sm align-middle mr-1">
warning
</span>
<strong>Remote access + Google OAuth:</strong> The default credentials only accept
redirects to <code>localhost</code>. After authorizing, your browser will try to
open <code>localhost</code> copy that full URL and paste it below. For fully
remote use without this manual step,{" "}
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
target="_blank"
rel="noreferrer"
className="underline"
>
configure your own OAuth credentials
</a>
.
<strong
dangerouslySetInnerHTML={{
__html: t("googleOAuthWarning")
.replace(
"<a>",
'<a href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server" target="_blank" rel="noreferrer" class="underline">'
)
.replace("</a>", "</a>"),
}}
/>
</div>
)}
{/* Generic remote info for other providers */}
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
<strong>Remote access:</strong> Since you&apos;re accessing OmniRoute remotely,
after authorizing you&apos;ll see an error page (localhost not found). That&apos;s
expected just copy the full URL from your browser&apos;s address bar and paste
it below.
{t("remoteAccessInfo")}
</div>
)}
<div>
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
<div className="flex gap-2">
<Input
value={authData?.authUrl || ""}
@@ -684,19 +678,21 @@ export default function OAuthModal({
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authData?.authUrl, "auth_url")}
>
Copy
{t("copy")}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">
Step 2: Paste the callback URL or auth code here
</p>
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
<p className="text-xs text-text-muted mb-2">
After authorization, paste the full callback URL. For Claude Code and Cline, you
can also paste the Authentication Code directly, for example{" "}
<code>code#state</code>.
<span
dangerouslySetInnerHTML={{
__html: t("step2Hint")
.replace("<code>", "<code>")
.replace("</code>", "</code>"),
}}
/>
</p>
<Input
value={callbackUrl}
@@ -713,10 +709,10 @@ export default function OAuthModal({
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl || !authData}>
Connect
{t("connect")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</>
@@ -730,12 +726,12 @@ export default function OAuthModal({
check_circle
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connected Successfully!</h3>
<h3 className="text-lg font-semibold mb-2">{t("success")}</h3>
<p className="text-sm text-text-muted mb-4">
Your {providerInfo.name} account has been connected.
{t("successMessage", { providerName: providerInfo.name })}
</p>
<Button onClick={onClose} fullWidth>
Done
{t("done")}
</Button>
</div>
)}
@@ -746,14 +742,14 @@ export default function OAuthModal({
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
<h3 className="text-lg font-semibold mb-2">{t("error")}</h3>
<p className="text-sm text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
Try Again
{t("tryAgain")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>

View File

@@ -1,9 +1,11 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { getDefaultPricing, formatCost } from "@/shared/constants/pricing";
export default function PricingModal({ isOpen, onClose, onSave }) {
const t = useTranslations("pricingModal");
const [pricingData, setPricingData] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -62,18 +64,18 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
onClose();
} else {
const error = await response.json();
alert(`Failed to save pricing: ${error.error}`);
alert(`${t("errorSaveFailed")}: ${error.error}`);
}
} catch (error) {
console.error("Failed to save pricing:", error);
alert("Failed to save pricing");
alert(t("errorSaveFailed"));
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm("Reset all pricing to defaults? This cannot be undone.")) return;
if (!confirm(t("resetConfirm"))) return;
try {
const response = await fetch("/api/pricing", { method: "DELETE" });
@@ -83,7 +85,7 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
}
} catch (error) {
console.error("Failed to reset pricing:", error);
alert("Failed to reset pricing");
alert(t("errorResetFailed"));
}
};
@@ -98,7 +100,7 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
<div className="bg-bg-base border border-border rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-semibold">Pricing Configuration</h2>
<h2 className="text-xl font-semibold">{t("title")}</h2>
<button
onClick={onClose}
className="text-text-muted hover:text-text text-2xl leading-none"
@@ -110,15 +112,20 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="text-center py-8 text-text-muted">Loading pricing data...</div>
<div className="text-center py-8 text-text-muted">{t("loading")}</div>
) : (
<div className="space-y-6">
{/* Instructions */}
<div className="bg-bg-subtle border border-border rounded-lg p-3 text-sm">
<p className="font-medium mb-1">Pricing Rates Format</p>
<p className="font-medium mb-1">{t("pricingRatesFormat")}</p>
<p className="text-text-muted">
All rates are in <strong>dollars per million tokens</strong> ($/1M tokens).
Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.
<span
dangerouslySetInnerHTML={{
__html: t("ratesDescription")
.replace("<strong>", "<strong>")
.replace("</strong>", "</strong>"),
}}
/>
</p>
</div>
@@ -134,12 +141,12 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
<table className="w-full text-sm">
<thead className="bg-bg-hover text-text-muted uppercase text-xs">
<tr>
<th className="px-3 py-2 text-left">Model</th>
<th className="px-3 py-2 text-right">Input</th>
<th className="px-3 py-2 text-right">Output</th>
<th className="px-3 py-2 text-right">Cached</th>
<th className="px-3 py-2 text-right">Reasoning</th>
<th className="px-3 py-2 text-right">Cache Creation</th>
<th className="px-3 py-2 text-left">{t("model")}</th>
<th className="px-3 py-2 text-right">{t("input")}</th>
<th className="px-3 py-2 text-right">{t("output")}</th>
<th className="px-3 py-2 text-right">{t("cached")}</th>
<th className="px-3 py-2 text-right">{t("reasoning")}</th>
<th className="px-3 py-2 text-right">{t("cacheCreation")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -170,7 +177,7 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
})}
{allProviders.length === 0 && (
<div className="text-center py-8 text-text-muted">No pricing data available</div>
<div className="text-center py-8 text-text-muted">{t("noPricingData")}</div>
)}
</div>
)}
@@ -183,7 +190,7 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
className="px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
disabled={saving}
>
Reset to Defaults
{t("resetToDefaults")}
</button>
<div className="flex gap-2">
<button
@@ -191,14 +198,14 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
className="px-4 py-2 text-sm text-text-muted hover:text-text border border-border rounded transition-colors"
disabled={saving}
>
Cancel
{t("cancel")}
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
disabled={saving}
>
{saving ? "Saving..." : "Save Changes"}
{saving ? t("saving") : t("saveChanges")}
</button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
@@ -15,14 +16,6 @@ const PROXY_TYPES = SOCKS5_UI_ENABLED
? ALL_PROXY_TYPES
: ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
const LEVEL_LABELS = {
global: "Global",
provider: "Provider",
combo: "Combo",
key: "Key",
direct: "Direct (none)",
};
/**
* ProxyConfigModal — Reusable proxy configuration modal for all 4 levels
* @param {Object} props
@@ -48,6 +41,7 @@ export default function ProxyConfigModal({
levelLabel?: any;
onSaved?: any;
}) {
const t = useTranslations("proxyConfigModal");
const [mode, setMode] = useState("saved");
const [savedProxies, setSavedProxies] = useState([]);
const [selectedProxyId, setSelectedProxyId] = useState("");
@@ -128,9 +122,7 @@ export default function ProxyConfigModal({
setShowAuth(!!(proxy.username || proxy.password));
setHasOwnProxy(true);
if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) {
setFormError(
"SOCKS5 is configured but hidden because NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false."
);
setFormError(t("errorSocks5Hidden"));
}
if (!hasSavedAssignment) setMode("custom");
} else {
@@ -150,12 +142,15 @@ export default function ProxyConfigModal({
// Determine inheritance source
if (level === "key") {
// Check combo, provider, global
if (config.global) setInheritedFrom({ level: "Global", proxy: config.global });
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
// Provider info requires more context, showing global as fallback
} else if (level === "combo") {
if (config.global) setInheritedFrom({ level: "Global", proxy: config.global });
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
} else if (level === "provider") {
if (config.global) setInheritedFrom({ level: "Global", proxy: config.global });
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
}
}
}
@@ -181,7 +176,7 @@ export default function ProxyConfigModal({
const handleSave = async () => {
if (mode === "saved" && !selectedProxyId) {
setFormError("Select a saved proxy before saving.");
setFormError(t("errorSelectSavedProxy"));
return;
}
if (mode === "custom" && !host.trim()) return;
@@ -218,7 +213,7 @@ export default function ProxyConfigModal({
});
const clearAssignmentPayload = await clearAssignmentRes.json().catch(() => ({}));
if (!clearAssignmentRes.ok) {
setFormError(clearAssignmentPayload?.error?.message || "Failed to clear saved proxy");
setFormError(clearAssignmentPayload?.error?.message || t("errorClearSavedProxy"));
return;
}
@@ -237,7 +232,7 @@ export default function ProxyConfigModal({
}
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to save proxy configuration");
setFormError(payload?.error?.message || t("errorSaveProxy"));
return;
}
setHasOwnProxy(true);
@@ -248,7 +243,7 @@ export default function ProxyConfigModal({
onClose();
} catch (error) {
console.error("Error saving proxy:", error);
setFormError(error.message || "Failed to save proxy configuration");
setFormError(error.message || t("errorSaveProxy"));
} finally {
setSaving(false);
}
@@ -274,7 +269,7 @@ export default function ProxyConfigModal({
const res = await fetch(`/api/settings/proxy?${params}`, { method: "DELETE" });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to clear proxy configuration");
setFormError(payload?.error?.message || t("errorClearProxy"));
return;
}
resetFields();
@@ -285,7 +280,7 @@ export default function ProxyConfigModal({
onClose();
} catch (error) {
console.error("Error clearing proxy:", error);
setFormError(error.message || "Failed to clear proxy configuration");
setFormError(error.message || t("errorClearProxy"));
} finally {
setSaving(false);
}
@@ -306,13 +301,13 @@ export default function ProxyConfigModal({
if (mode === "saved") {
if (!selectedProxyId) {
setFormError("Select a saved proxy first.");
setFormError(t("errorSelectProxyFirst"));
setTesting(false);
return;
}
const found = (savedProxies as any[]).find((p: any) => p.id === selectedProxyId);
if (!found) {
setFormError("Selected proxy not found.");
setFormError(t("errorProxyNotFound"));
setTesting(false);
return;
}
@@ -342,7 +337,7 @@ export default function ProxyConfigModal({
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data?.error?.message || "Connection failed";
const message = data?.error?.message || t("connectionFailed");
setTestResult({ success: false, error: message });
setFormError(message);
return;
@@ -350,7 +345,7 @@ export default function ProxyConfigModal({
setTestResult(data);
} catch (error) {
setTestResult({ success: false, error: error.message });
setFormError(error.message || "Connection failed");
setFormError(error.message || t("connectionFailed"));
} finally {
setTesting(false);
}
@@ -358,15 +353,13 @@ export default function ProxyConfigModal({
const title =
level === "global"
? "Global Proxy Configuration"
: `${LEVEL_LABELS[level]} Proxy — ${levelLabel || levelId || ""}`;
? t("titleGlobal")
: `${t(`level${level.charAt(0).toUpperCase() + level.slice(1)}` as any)} Proxy — ${levelLabel || levelId || ""}`;
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} maxWidth="lg">
{loading ? (
<div className="py-8 text-center text-text-muted animate-pulse">
Loading proxy configuration...
</div>
<div className="py-8 text-center text-text-muted animate-pulse">{t("loading")}</div>
) : (
<div className="flex flex-col gap-5">
{/* Inheritance indicator */}
@@ -376,7 +369,8 @@ export default function ProxyConfigModal({
subdirectory_arrow_right
</span>
<span className="text-blue-300">
Inheriting from <strong>{inheritedFrom.level}</strong>: {inheritedFrom.proxy?.type}
{t("inheritingFrom")} <strong>{inheritedFrom.level}</strong>:{" "}
{inheritedFrom.proxy?.type}
://{inheritedFrom.proxy?.host}:{inheritedFrom.proxy?.port}
</span>
</div>
@@ -385,7 +379,7 @@ export default function ProxyConfigModal({
{/* Proxy Type Selector */}
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Source
{t("source")}
</label>
<div className="flex gap-2">
<button
@@ -396,7 +390,7 @@ export default function ProxyConfigModal({
: "bg-bg-subtle text-text-muted border-border"
}`}
>
Saved Proxy
{t("savedProxy")}
</button>
<button
onClick={() => setMode("custom")}
@@ -406,7 +400,7 @@ export default function ProxyConfigModal({
: "bg-bg-subtle text-text-muted border-border"
}`}
>
Custom
{t("custom")}
</button>
</div>
</div>
@@ -414,14 +408,14 @@ export default function ProxyConfigModal({
{mode === "saved" && (
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Saved Proxy
{t("savedProxy")}
</label>
<select
value={selectedProxyId}
onChange={(e) => setSelectedProxyId(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary"
>
<option value="">Select saved proxy...</option>
<option value="">{t("selectSavedProxyPlaceholder")}</option>
{savedProxies.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
@@ -435,7 +429,7 @@ export default function ProxyConfigModal({
<>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
{t("proxyType")}
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
@@ -458,19 +452,19 @@ export default function ProxyConfigModal({
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Host
{t("host")}
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
placeholder={t("hostPlaceholder")}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Port
{t("port")}
</label>
<input
type="text"
@@ -491,31 +485,31 @@ export default function ProxyConfigModal({
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
Authentication (optional)
{t("authOptional")}
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Username
{t("username")}
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
placeholder={t("usernamePlaceholder")}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Password
{t("password")}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
placeholder={t("passwordPlaceholder")}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
@@ -550,15 +544,16 @@ export default function ProxyConfigModal({
<div className="flex-1">
{testResult.success ? (
<div>
<span className="text-sm font-medium text-emerald-400">Connected</span>
<span className="text-sm font-medium text-emerald-400">{t("connected")}</span>
<span className="text-text-muted text-xs ml-2">
IP: <span className="font-mono text-emerald-300">{testResult.publicIp}</span>
{t("ip")}{" "}
<span className="font-mono text-emerald-300">{testResult.publicIp}</span>
{testResult.latencyMs && ` · ${testResult.latencyMs}ms`}
</span>
</div>
) : (
<div className="text-sm text-red-400">
{testResult.error || "Connection failed"}
{testResult.error || t("connectionFailed")}
{testResult.latencyMs && (
<span className="text-text-muted text-xs ml-2">
({testResult.latencyMs}ms)
@@ -581,7 +576,7 @@ export default function ProxyConfigModal({
loading={testing}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Test Connection
{t("testConnection")}
</Button>
{hasOwnProxy && (
<Button
@@ -592,13 +587,13 @@ export default function ProxyConfigModal({
disabled={saving}
className="!text-red-400 hover:!bg-red-500/10"
>
Clear
{t("clear")}
</Button>
)}
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onClose}>
Cancel
{t("cancel")}
</Button>
<Button
size="sm"
@@ -607,7 +602,7 @@ export default function ProxyConfigModal({
loading={saving}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Save
{t("save")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import Card from "./Card";
import RequestLoggerDetail from "./RequestLoggerDetail";
import { copyToClipboard } from "@/shared/utils/clipboard";
@@ -42,7 +43,7 @@ const COLUMNS = [
{ key: "time", label: "Time" },
];
const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
// Default visible columns will be generated dynamically with translations
/**
* Get a friendly display label for compatible providers.
@@ -112,6 +113,31 @@ function getCacheSourceMeta(cacheSource: unknown) {
}
export default function RequestLoggerV2() {
const t = useTranslations("requestLogger");
// Get translated status filters
const getStatusFilters = () => [
{ key: "all", label: t("statusFilters.all"), icon: "" },
{ key: "error", label: t("statusFilters.error"), icon: "error" },
{ key: "ok", label: t("statusFilters.success"), icon: "check_circle" },
{ key: "combo", label: t("statusFilters.combo"), icon: "hub" },
];
// Get translated columns
const getColumns = () => [
{ key: "status", label: t("columns.status") },
{ key: "model", label: t("columns.model") },
{ key: "requestedModel", label: t("columns.requested") },
{ key: "provider", label: t("columns.provider") },
{ key: "protocol", label: t("columns.protocol") },
{ key: "account", label: t("columns.account") },
{ key: "apiKey", label: t("columns.apiKey") },
{ key: "combo", label: t("columns.combo") },
{ key: "tokens", label: t("columns.tokens") },
{ key: "duration", label: t("columns.duration") },
{ key: "time", label: t("columns.time") },
];
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [recording, setRecording] = useState(true);
@@ -133,12 +159,13 @@ export default function RequestLoggerV2() {
// Column visibility with localStorage persistence
const [visibleColumns, setVisibleColumns] = useState(() => {
if (typeof window === "undefined") return DEFAULT_VISIBLE;
const defaultVisible = Object.fromEntries(getColumns().map((c) => [c.key, true]));
if (typeof window === "undefined") return defaultVisible;
try {
const saved = localStorage.getItem("loggerVisibleColumns");
return saved ? { ...DEFAULT_VISIBLE, ...JSON.parse(saved) } : DEFAULT_VISIBLE;
return saved ? { ...defaultVisible, ...JSON.parse(saved) } : defaultVisible;
} catch {
return DEFAULT_VISIBLE;
return defaultVisible;
}
});
@@ -337,7 +364,7 @@ export default function RequestLoggerV2() {
<span
className={`w-2 h-2 rounded-full ${recording ? "bg-red-500 animate-pulse" : "bg-text-muted"}`}
/>
{recording ? "Recording" : "Paused"}
{recording ? t("recording") : t("paused")}
</button>
<button
@@ -354,10 +381,10 @@ export default function RequestLoggerV2() {
className={`w-2 h-2 rounded-full ${detailLoggingEnabled ? "bg-amber-500" : "bg-text-muted"}`}
/>
{detailLoggingLoading
? "Updating pipeline logs..."
? t("updatingPipelineLogs")
: detailLoggingEnabled
? "Pipeline Logs On"
: "Pipeline Logs Off"}
? t("pipelineLogsOn")
: t("pipelineLogsOff")}
</button>
{/* Search */}
@@ -367,7 +394,7 @@ export default function RequestLoggerV2() {
</span>
<input
type="text"
placeholder="Search model, provider, account, API key, combo..."
placeholder={t("searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
@@ -380,7 +407,7 @@ export default function RequestLoggerV2() {
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
>
<option value="">All Providers</option>
<option value="">{t("allProviders")}</option>
{uniqueProviders.map((p) => {
const compatLabel = getProviderDisplayLabel(p, providerNodes);
const pc = PROVIDER_COLORS[p];
@@ -398,7 +425,7 @@ export default function RequestLoggerV2() {
onChange={(e) => setSelectedModel(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[180px]"
>
<option value="">All Models</option>
<option value="">{t("allModels")}</option>
{uniqueModels.map((model) => (
<option key={model} value={model}>
{model}
@@ -412,7 +439,7 @@ export default function RequestLoggerV2() {
onChange={(e) => setSelectedAccount(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
>
<option value="">All Accounts</option>
<option value="">{t("allAccounts")}</option>
{uniqueAccounts.map((a) => (
<option key={a} value={a}>
{a}
@@ -426,7 +453,7 @@ export default function RequestLoggerV2() {
onChange={(e) => setSelectedApiKey(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[160px]"
>
<option value="">All API Keys</option>
<option value="">{t("allApiKeys")}</option>
{uniqueApiKeys.map((value) => {
const matched = logs.find((l) => (l.apiKeyId || l.apiKeyName) === value);
const label = formatApiKeyLabel(matched?.apiKeyName, matched?.apiKeyId);
@@ -441,28 +468,28 @@ export default function RequestLoggerV2() {
{/* Stats */}
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
{totalCount} total
{totalCount} {t("total")}
</span>
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 font-mono">
{okCount} OK
{okCount} {t("ok")}
</span>
{errorCount > 0 && (
<span className="px-2 py-1 rounded bg-red-500/10 text-red-700 dark:text-red-400 font-mono">
{errorCount} ERR
{errorCount} {t("err")}
</span>
)}
{comboCount > 0 && (
<span className="px-2 py-1 rounded bg-violet-500/10 text-violet-700 dark:text-violet-400 font-mono">
{comboCount} combo
{comboCount} {t("combo")}
</span>
)}
{apiKeyCount > 0 && (
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-mono">
{apiKeyCount} keys
{apiKeyCount} {t("keys")}
</span>
)}
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
{sortedLogs.length} shown
{sortedLogs.length} {t("shown")}
</span>
</div>
@@ -473,18 +500,16 @@ export default function RequestLoggerV2() {
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[150px]"
title="Sort logs"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="tokens_desc">Tokens </option>
<option value="tokens_asc">Tokens </option>
<option value="duration_desc">Duration </option>
<option value="duration_asc">Duration </option>
<option value="tps_desc">TPS </option>
<option value="tps_asc">TPS </option>
<option value="status_desc">Status </option>
<option value="status_asc">Status </option>
<option value="model_asc">Model A-Z</option>
<option value="model_desc">Model Z-A</option>
<option value="newest">{t("sortNewest")}</option>
<option value="oldest">{t("sortOldest")}</option>
<option value="tokens_desc">{t("sortTokensDesc")}</option>
<option value="tokens_asc">{t("sortTokensAsc")}</option>
<option value="duration_desc">{t("sortDurationDesc")}</option>
<option value="duration_asc">{t("sortDurationAsc")}</option>
<option value="status_desc">{t("sortStatusDesc")}</option>
<option value="status_asc">{t("sortStatusAsc")}</option>
<option value="model_asc">{t("sortModelAsc")}</option>
<option value="model_desc">{t("sortModelDesc")}</option>
</select>
{/* Refresh */}
@@ -500,7 +525,7 @@ export default function RequestLoggerV2() {
{/* Quick Filters */}
<div className="flex flex-wrap items-center gap-2">
{/* Status Filters */}
{STATUS_FILTERS.map((f) => (
{getStatusFilters().map((f) => (
<button
key={f.key}
onClick={() => setActiveFilter(activeFilter === f.key ? "all" : f.key)}
@@ -557,7 +582,7 @@ export default function RequestLoggerV2() {
{/* Column Visibility Toggles */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-text-muted uppercase tracking-wider mr-1">Columns</span>
{COLUMNS.map((col) => (
{getColumns().map((col) => (
<button
key={col.key}
onClick={() => toggleColumn(col.key)}
@@ -576,18 +601,16 @@ export default function RequestLoggerV2() {
<Card className="overflow-hidden bg-black/5 dark:bg-black/20">
<div className="p-0 overflow-x-auto max-h-[calc(100vh-320px)] overflow-y-auto">
{loading && logs.length === 0 ? (
<div className="p-8 text-center text-text-muted">Loading logs...</div>
<div className="p-8 text-center text-text-muted">{t("loadingLogs")}</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-2 block opacity-40">
receipt_long
</span>
No logs recorded yet. Make some API calls to see them here.
{t("noLogs")}
</div>
) : sortedLogs.length === 0 ? (
<div className="p-8 text-center text-text-muted">
No logs match the current filters.
</div>
<div className="p-8 text-center text-text-muted">{t("noMatchingLogs")}</div>
) : (
<table className="w-full text-left border-collapse text-xs">
<thead
@@ -600,7 +623,7 @@ export default function RequestLoggerV2() {
>
{visibleColumns.status && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Status
{t("columns.status")}
</th>
)}
{visibleColumns.cacheSource && (
@@ -610,42 +633,42 @@ export default function RequestLoggerV2() {
)}
{visibleColumns.model && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Model
{t("columns.model")}
</th>
)}
{visibleColumns.requestedModel && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Requested
{t("columns.requested")}
</th>
)}
{visibleColumns.provider && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Provider
{t("columns.provider")}
</th>
)}
{visibleColumns.protocol && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Req Protocol
{t("columns.protocol")}
</th>
)}
{visibleColumns.account && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Account
{t("columns.account")}
</th>
)}
{visibleColumns.apiKey && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
API Key
{t("columns.apiKey")}
</th>
)}
{visibleColumns.combo && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Combo
{t("columns.combo")}
</th>
)}
{visibleColumns.tokens && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Tokens
{t("columns.tokens")}
</th>
)}
{visibleColumns.tps && (
@@ -655,12 +678,12 @@ export default function RequestLoggerV2() {
)}
{visibleColumns.duration && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Duration
{t("columns.duration")}
</th>
)}
{visibleColumns.time && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Time
{t("columns.time")}
</th>
)}
</tr>
@@ -838,8 +861,11 @@ export default function RequestLoggerV2() {
</Card>
<div className="text-[10px] text-text-muted italic">
Call logs are also saved as JSON files to <code>{`{DATA_DIR}/call_logs/`}</code> and rotated
by <code>CALL_LOG_RETENTION_DAYS</code> and <code>CALL_LOG_MAX_ENTRIES</code>.
{t("callLogsInfo", {
dataDir: "{DATA_DIR}/call_logs/",
retentionDays: "CALL_LOG_RETENTION_DAYS",
maxEntries: "CALL_LOG_MAX_ENTRIES",
})}
</div>
{/* Detail Modal */}