mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}进行轮换。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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're accessing OmniRoute remotely,
|
||||
after authorizing you'll see an error page (localhost not found). That's
|
||||
expected — just copy the full URL from your browser'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user