feat(i18n): migrate Combos page (1123 lines, 50+ strings)

- CombosPage, ComboCard, ComboFormModal, TestResultsView: full migration
- Headers, model tags, metrics, strategy descriptions, validation
- Form labels, advanced settings, drag-and-drop model list, actions
- Expanded combos namespace from 19 to 65 keys in en.json and pt-BR.json
This commit is contained in:
diegosouzapw
2026-02-25 14:03:46 -03:00
parent e1fe304dd3
commit 27b9c331b7
3 changed files with 160 additions and 63 deletions

View File

@@ -14,6 +14,7 @@ import {
} from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
@@ -34,6 +35,8 @@ function getModelString(entry) {
// Main Page
// ─────────────────────────────────────────────
export default function CombosPage() {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -91,13 +94,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setShowCreateModal(false);
notify.success("Combo created successfully");
notify.success(t("comboCreated"));
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to create combo");
notify.error(err.error?.message || err.error || t("failedCreate"));
}
} catch (error) {
notify.error("Error creating combo");
notify.error(t("errorCreating"));
}
};
@@ -111,26 +114,26 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setEditingCombo(null);
notify.success("Combo updated successfully");
notify.success(t("comboUpdated"));
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to update combo");
notify.error(err.error?.message || err.error || t("failedUpdate"));
}
} catch (error) {
notify.error("Error updating combo");
notify.error(t("errorUpdating"));
}
};
const handleDelete = async (id) => {
if (!confirm("Delete this combo?")) return;
if (!confirm(t("deleteConfirm"))) return;
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter((c) => c.id !== id));
notify.success("Combo deleted");
notify.success(t("comboDeleted"));
}
} catch (error) {
notify.error("Error deleting combo");
notify.error(t("errorDeleting"));
}
};
@@ -166,8 +169,8 @@ export default function CombosPage() {
const data = await res.json();
setTestResults(data);
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Test request failed");
setTestResults({ error: t("testFailed") });
notify.error(t("testFailed"));
}
};
@@ -186,7 +189,7 @@ export default function CombosPage() {
setCombos((prev) =>
prev.map((c) => (c.id === combo.id ? { ...c, isActive: !newActive } : c))
);
notify.error("Failed to toggle combo");
notify.error(t("failedToggle"));
}
};
@@ -204,13 +207,11 @@ export default function CombosPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
Create model combos with weighted routing and fallback support
</p>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
{t("createCombo")}
</Button>
</div>
@@ -218,9 +219,9 @@ export default function CombosPage() {
{combos.length === 0 ? (
<EmptyState
icon="🧩"
title="No combos yet"
description="Create model combos with weighted routing and fallback support"
actionLabel="Create Combo"
title={t("noCombosYet")}
description={t("description")}
actionLabel={t("createCombo")}
onAction={() => setShowCreateModal(true)}
/>
) : (
@@ -253,7 +254,7 @@ export default function CombosPage() {
setTestResults(null);
setTestingCombo(null);
}}
title={`Test Results${testingCombo}`}
title={t("testResults", { name: testingCombo })}
>
<TestResultsView results={testResults} />
</Modal>
@@ -313,6 +314,8 @@ function ComboCard({
const strategy = combo.strategy || "priority";
const models = combo.models || [];
const isDisabled = combo.isActive === false;
const t = useTranslations("combos");
const tc = useTranslations("common");
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
@@ -346,7 +349,7 @@ function ComboCard({
{hasProxy && (
<span
className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-primary/15 text-primary flex items-center gap-0.5"
title="Proxy configured"
title={t("proxyConfigured")}
>
<span className="material-symbols-outlined text-[11px]">vpn_lock</span>
proxy
@@ -358,7 +361,7 @@ function ComboCard({
onCopy(combo.name, `combo-${combo.id}`);
}}
className="p-0.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors opacity-0 group-hover:opacity-100"
title="Copy combo name"
title={t("copyComboName")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
@@ -369,7 +372,7 @@ function ComboCard({
{/* Model tags with weights */}
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span>
<span className="text-xs text-text-muted italic">{t("noModels")}</span>
) : (
models.slice(0, 3).map((entry, index) => {
const { model, weight } = normalizeModelEntry(entry);
@@ -385,7 +388,9 @@ function ComboCard({
})
)}
{models.length > 3 && (
<span className="text-[10px] text-text-muted">+{models.length - 3} more</span>
<span className="text-[10px] text-text-muted">
{t("more", { count: models.length - 3 })}
</span>
)}
</div>
@@ -394,9 +399,11 @@ function ComboCard({
<div className="flex items-center gap-3 mt-1">
<span className="text-[10px] text-text-muted">
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
{metrics.totalRequests} reqs
{metrics.totalRequests} {t("reqs")}
</span>
<span className="text-[10px] text-text-muted">
{metrics.successRate}% {t("success")}
</span>
<span className="text-[10px] text-text-muted">{metrics.successRate}% success</span>
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
{metrics.fallbackRate > 0 && (
<span className="text-[10px] text-amber-500">
@@ -414,14 +421,14 @@ function ComboCard({
size="sm"
checked={!isDisabled}
onChange={onToggle}
title={isDisabled ? "Enable combo" : "Disable combo"}
title={isDisabled ? t("enableCombo") : t("disableCombo")}
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
title={t("testCombo")}
>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
@@ -432,28 +439,28 @@ function ComboCard({
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
title={t("duplicate")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
title={t("proxyConfig")}
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
title={tc("edit")}
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
title={tc("delete")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -531,6 +538,8 @@ function TestResultsView({ results }) {
// Combo Form Modal
// ─────────────────────────────────────────────
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(() => {
return (combo?.models || []).map((m) => normalizeModelEntry(m));
@@ -575,11 +584,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const validateName = (value) => {
if (!value.trim()) {
setNameError("Name is required");
setNameError(t("nameRequired"));
return false;
}
if (!VALID_NAME_REGEX.test(value)) {
setNameError("Only letters, numbers, -, _, / and . allowed");
setNameError(t("nameInvalid"));
return false;
}
setNameError("");
@@ -726,25 +735,23 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? "Edit Combo" : "Create Combo"}>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
<div className="flex flex-col gap-3">
{/* Name */}
<div>
<Input
label="Combo Name"
label={t("comboName")}
value={name}
onChange={handleNameChange}
placeholder="my-combo"
placeholder={t("comboNamePlaceholder")}
error={nameError}
/>
<p className="text-[10px] text-text-muted mt-0.5">
Letters, numbers, -, _, / and . allowed
</p>
<p className="text-[10px] text-text-muted mt-0.5">{t("nameHint")}</p>
</div>
{/* Strategy Toggle */}
<div>
<label className="text-sm font-medium mb-1.5 block">Routing Strategy</label>
<label className="text-sm font-medium mb-1.5 block">{t("routingStrategy")}</label>
<div className="grid grid-cols-3 gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
{[
{ value: "priority", label: "Priority", icon: "sort" },
@@ -788,13 +795,13 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Models */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">Models</label>
<label className="text-sm font-medium">{t("models")}</label>
{strategy === "weighted" && models.length > 1 && (
<button
onClick={handleAutoBalance}
className="text-[10px] text-primary hover:text-primary/80 transition-colors"
>
Auto-balance
{t("autoBalance")}
</button>
)}
</div>
@@ -804,7 +811,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-text-muted text-xl mb-1">
layers
</span>
<p className="text-xs text-text-muted">No models added yet</p>
<p className="text-xs text-text-muted">{t("noModelsYet")}</p>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[240px] overflow-y-auto">
@@ -900,7 +907,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-text-muted hover:text-primary hover:border-primary/30 transition-colors flex items-center justify-center gap-1"
>
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
{t("addModel")}
</button>
</div>
@@ -912,14 +919,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-[14px]">
{showAdvanced ? "expand_less" : "expand_more"}
</span>
Advanced Settings
{t("advancedSettings")}
</button>
{showAdvanced && (
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">Max Retries</label>
<label className="text-[10px] text-text-muted mb-0.5 block">
{t("maxRetries")}
</label>
<input
type="number"
min="0"
@@ -937,7 +946,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Retry Delay (ms)
{t("retryDelay")}
</label>
<input
type="number"
@@ -956,7 +965,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
/>
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">Timeout (ms)</label>
<label className="text-[10px] text-text-muted mb-0.5 block">{t("timeout")}</label>
<input
type="number"
min="1000"
@@ -974,7 +983,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-text-muted">Healthcheck</label>
<label className="text-[10px] text-text-muted">{t("healthcheck")}</label>
<input
type="checkbox"
checked={config.healthCheckEnabled !== false}
@@ -987,7 +996,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Concurrency / Model
{t("concurrencyPerModel")}
</label>
<input
type="number"
@@ -1006,7 +1015,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Queue Timeout (ms)
{t("queueTimeout")}
</label>
<input
type="number"
@@ -1026,16 +1035,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
</div>
)}
<p className="text-[10px] text-text-muted">
Leave empty to use global defaults. These override per-provider settings.
</p>
<p className="text-[10px] text-text-muted">{t("advancedHint")}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
Cancel
{tc("cancel")}
</Button>
<Button
onClick={handleSave}
@@ -1043,7 +1050,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
size="sm"
disabled={!name.trim() || !!nameError || saving}
>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
{saving ? t("saving") : isEdit ? tc("save") : t("createCombo")}
</Button>
</div>
</div>
@@ -1056,7 +1063,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onSelect={handleAddModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
title={t("addModelToCombo")}
selectedModel={null}
/>
</>

View File

@@ -246,12 +246,14 @@
},
"combos": {
"title": "Combos",
"description": "Create model combos with weighted routing and fallback support",
"createCombo": "Create Combo",
"editCombo": "Edit Combo",
"deleteCombo": "Delete Combo",
"noModels": "No models",
"noModelsYet": "No models added yet",
"addModel": "Add Model",
"addModelToCombo": "Add Model to Combo",
"routingStrategy": "Routing Strategy",
"maxRetries": "Max Retries",
"timeout": "Timeout (ms)",
@@ -262,8 +264,51 @@
"random": "Random",
"leastLatency": "Least Latency",
"comboName": "Combo Name",
"comboNamePlaceholder": "e.g. my-smart-combo",
"deleteConfirm": "Are you sure you want to delete this combo?"
"comboNamePlaceholder": "my-combo",
"deleteConfirm": "Delete this combo?",
"noCombosYet": "No combos yet",
"comboCreated": "Combo created successfully",
"comboUpdated": "Combo updated successfully",
"comboDeleted": "Combo deleted",
"failedCreate": "Failed to create combo",
"failedUpdate": "Failed to update combo",
"errorCreating": "Error creating combo",
"errorUpdating": "Error updating combo",
"errorDeleting": "Error deleting combo",
"testFailed": "Test request failed",
"failedToggle": "Failed to toggle combo",
"testResults": "Test Results — {name}",
"resolvedBy": "Resolved by:",
"more": "+{count} more",
"reqs": "reqs",
"success": "success",
"proxyConfigured": "Proxy configured",
"copyComboName": "Copy combo name",
"enableCombo": "Enable combo",
"disableCombo": "Disable combo",
"testCombo": "Test combo",
"duplicate": "Duplicate",
"proxyConfig": "Proxy configuration",
"nameRequired": "Name is required",
"nameInvalid": "Only letters, numbers, -, _, / and . allowed",
"nameHint": "Letters, numbers, -, _, / and . allowed",
"priorityDesc": "Sequential fallback: tries model 1 first, then 2, etc.",
"weightedDesc": "Distributes traffic by weight percentage with fallback",
"roundRobinDesc": "Circular distribution: each request goes to the next model in rotation",
"randomDesc": "Uniform random selection, then fallback to remaining models",
"leastUsedDesc": "Picks the model with fewest requests, balancing load over time",
"costOptimizedDesc": "Routes to the cheapest model first based on pricing",
"models": "Models",
"autoBalance": "Auto-balance",
"advancedSettings": "Advanced Settings",
"retryDelay": "Retry Delay (ms)",
"concurrencyPerModel": "Concurrency / Model",
"queueTimeout": "Queue Timeout (ms)",
"advancedHint": "Leave empty to use global defaults. These override per-provider settings.",
"saving": "Saving...",
"weighted": "Weighted",
"leastUsed": "Least-Used",
"costOpt": "Cost-Opt"
},
"costs": {
"title": "Costs",

View File

@@ -246,12 +246,14 @@
},
"combos": {
"title": "Combos",
"description": "Crie combos de modelos com roteamento ponderado e suporte a fallback",
"createCombo": "Criar Combo",
"editCombo": "Editar Combo",
"deleteCombo": "Excluir Combo",
"noModels": "Sem modelos",
"noModelsYet": "Nenhum modelo adicionado",
"addModel": "Adicionar Modelo",
"addModelToCombo": "Adicionar Modelo ao Combo",
"routingStrategy": "Estratégia de Roteamento",
"maxRetries": "Máximo de Tentativas",
"timeout": "Timeout (ms)",
@@ -262,8 +264,51 @@
"random": "Aleatório",
"leastLatency": "Menor Latência",
"comboName": "Nome do Combo",
"comboNamePlaceholder": "ex: meu-combo-inteligente",
"deleteConfirm": "Tem certeza que deseja excluir este combo?"
"comboNamePlaceholder": "meu-combo",
"deleteConfirm": "Excluir este combo?",
"noCombosYet": "Nenhum combo ainda",
"comboCreated": "Combo criado com sucesso",
"comboUpdated": "Combo atualizado com sucesso",
"comboDeleted": "Combo excluído",
"failedCreate": "Falha ao criar combo",
"failedUpdate": "Falha ao atualizar combo",
"errorCreating": "Erro ao criar combo",
"errorUpdating": "Erro ao atualizar combo",
"errorDeleting": "Erro ao excluir combo",
"testFailed": "Requisição de teste falhou",
"failedToggle": "Falha ao alternar combo",
"testResults": "Resultados do Teste \u2014 {name}",
"resolvedBy": "Resolvido por:",
"more": "+{count} mais",
"reqs": "reqs",
"success": "sucesso",
"proxyConfigured": "Proxy configurado",
"copyComboName": "Copiar nome do combo",
"enableCombo": "Ativar combo",
"disableCombo": "Desativar combo",
"testCombo": "Testar combo",
"duplicate": "Duplicar",
"proxyConfig": "Configuração de proxy",
"nameRequired": "Nome é obrigatório",
"nameInvalid": "Apenas letras, números, -, _, / e . permitidos",
"nameHint": "Letras, números, -, _, / e . permitidos",
"priorityDesc": "Fallback sequencial: tenta modelo 1 primeiro, depois 2, etc.",
"weightedDesc": "Distribui tráfego por porcentagem de peso com fallback",
"roundRobinDesc": "Distribuição circular: cada requisição vai para o próximo modelo na rotação",
"randomDesc": "Seleção aleatória uniforme, depois fallback para modelos restantes",
"leastUsedDesc": "Escolhe o modelo com menos requisições, equilibrando carga ao longo do tempo",
"costOptimizedDesc": "Roteia para o modelo mais barato primeiro baseado em preços",
"models": "Modelos",
"autoBalance": "Auto-balancear",
"advancedSettings": "Configurações Avançadas",
"retryDelay": "Intervalo de Tentativa (ms)",
"concurrencyPerModel": "Concorrência / Modelo",
"queueTimeout": "Timeout da Fila (ms)",
"advancedHint": "Deixe vazio para usar padrões globais. Estes substituem configurações por provedor.",
"saving": "Salvando...",
"weighted": "Ponderado",
"leastUsed": "Menos Usado",
"costOpt": "Custo-Otim"
},
"costs": {
"title": "Custos",