大量UI显示和i18n优化 (#3973)

Integrated into release/v3.8.27
This commit is contained in:
Randi
2026-06-16 08:57:21 -04:00
committed by GitHub
parent 09278297ae
commit bba97cfc60
36 changed files with 588 additions and 722 deletions

View File

@@ -897,6 +897,11 @@ The settings page is organized into **7 tabs** for easy navigation:
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
General no longer duplicates read-only logging and cache notes. Database retention and
optimization settings are persisted through `/api/settings/database`; manual cache clearing uses
`DELETE /api/cache`. Request and proxy log row caps are controlled by
`CALL_LOGS_TABLE_MAX_ROWS` and `PROXY_LOGS_TABLE_MAX_ROWS`.
---
### Costs & Budget Management

View File

@@ -806,65 +806,6 @@ export default function ApiManagerPageClient() {
</div>
)}
{/* Stats Summary Cards */}
{keys.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<span className="material-symbols-outlined text-primary text-lg">vpn_key</span>
</div>
<div>
<p className="text-2xl font-bold">{keys.length}</p>
<p className="text-xs text-text-muted">{t("totalKeys")}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-500/10">
<span className="material-symbols-outlined text-amber-500 text-lg">lock</span>
</div>
<div>
<p className="text-2xl font-bold">
{
keys.filter((k) => Array.isArray(k.allowedModels) && k.allowedModels.length > 0)
.length
}
</p>
<p className="text-xs text-text-muted">{t("restricted")}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-blue-500/10">
<span className="material-symbols-outlined text-blue-500 text-lg">bar_chart</span>
</div>
<div>
<p className="text-2xl font-bold">
{Object.values(usageStats).reduce((sum, s) => sum + s.totalRequests, 0)}
</p>
<p className="text-xs text-text-muted">{t("totalRequests")}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-emerald-500/10">
<span className="material-symbols-outlined text-emerald-500 text-lg">
model_training
</span>
</div>
<div>
<p className="text-2xl font-bold">{allModels.length}</p>
<p className="text-xs text-text-muted">{t("modelsAvailable")}</p>
</div>
</div>
</Card>
</div>
)}
{/* Filter Bar — shown when there are keys */}
{keys.length > 0 && (
<ApiKeyFilterBar
@@ -902,7 +843,6 @@ export default function ApiManagerPageClient() {
)}
</h3>
<p className="text-xs text-text-muted">
{keys.length}{" "}
{keys.length === 1
? t("keyRegistered", { count: keys.length })
: t("keysRegistered", { count: keys.length })}
@@ -1257,36 +1197,6 @@ export default function ApiManagerPageClient() {
)}
</Card>
{/* Usage Tips Card */}
<Card>
<div className="flex items-start gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-blue-500/10 shrink-0">
<span className="material-symbols-outlined text-xl text-blue-500">lightbulb</span>
</div>
<div>
<h3 className="font-semibold mb-2">{t("usageTips")}</h3>
<ul className="text-sm text-text-muted space-y-1.5">
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
<span>{t("tipAuth")}</span>
</li>
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
<span>{t("tipSecure")}</span>
</li>
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
<span>{t("tipSeparate")}</span>
</li>
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
<span>{t("tipRestrict")}</span>
</li>
</ul>
</div>
</div>
</Card>
{/* Add Key Modal */}
<Modal
isOpen={showAddModal}

View File

@@ -4,7 +4,7 @@
//
// IMPORTANT (hydration): no `useTranslations` here. The earlier combos redesign
// failed to hydrate on the production build and the only structural difference from
// the engine pages was a page-level `useTranslations`. Strings are hardcoded (pt-BR),
// the engine pages was a page-level `useTranslations`. Strings are literal English,
// matching `EngineConfigPage` / `CompressionHub`, both of which hydrate cleanly.
import { useEffect, useState } from "react";
@@ -141,7 +141,7 @@ function NamedCombosManager() {
};
const deleteCombo = async (combo: CompressionCombo) => {
if (!confirm(`Excluir o combo "${combo.name}"?`)) return;
if (!confirm(`Delete combo "${combo.name}"?`)) return;
const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" });
if (res.ok) refresh();
};
@@ -186,9 +186,9 @@ function NamedCombosManager() {
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold text-text-main">Combos nomeados</h2>
<h2 className="text-lg font-semibold text-text-main">Named combos</h2>
<p className="text-sm text-text-muted">
Salve pipelines diferentes e atribua a combos de roteamento específicos.
Save different pipelines and assign them to specific routing combos.
</p>
</div>
@@ -197,13 +197,13 @@ function NamedCombosManager() {
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nome do combo"
placeholder="Combo name"
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main"
/>
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Descrição"
placeholder="Description"
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main"
/>
</div>
@@ -217,7 +217,7 @@ function NamedCombosManager() {
}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
>
Adicionar etapa
Add step
</button>
</div>
{pipeline.map((step, index) => (
@@ -249,7 +249,7 @@ function NamedCombosManager() {
className="rounded-lg border border-border px-3 py-2 text-sm text-text-main"
disabled={pipeline.length <= 1}
>
Remover
Remove
</button>
</div>
))}
@@ -257,7 +257,7 @@ function NamedCombosManager() {
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div>
<h3 className="mb-2 text-sm font-semibold text-text-main">Pacotes de idioma</h3>
<h3 className="mb-2 text-sm font-semibold text-text-main">Language packs</h3>
<div className="space-y-2 text-sm text-text-main">
{languagePacks.map((pack) => (
<label key={pack.language} className="flex items-center justify-between gap-2">
@@ -282,7 +282,7 @@ function NamedCombosManager() {
checked={outputMode}
onChange={(event) => setOutputMode(event.target.checked)}
/>
Ativado
Enabled
</label>
<select
value={outputModeIntensity}
@@ -295,10 +295,10 @@ function NamedCombosManager() {
</select>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-text-main">Atribuir ao roteamento</h3>
<h3 className="mb-2 text-sm font-semibold text-text-main">Assign to routing</h3>
<div className="max-h-44 space-y-2 overflow-auto text-sm text-text-main">
{routingCombos.length === 0 ? (
<p className="text-xs text-text-muted">Nenhum combo de roteamento disponível.</p>
<p className="text-xs text-text-muted">No routing combos available.</p>
) : (
routingCombos.map((combo) => {
const id = combo.id ?? combo.name ?? "";
@@ -325,14 +325,14 @@ function NamedCombosManager() {
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white"
>
{editingId ? "Salvar" : "Criar combo"}
{editingId ? "Save" : "Create combo"}
</button>
{editingId && (
<button
onClick={resetForm}
className="rounded-lg border border-border px-4 py-2 text-sm text-text-main"
>
Cancelar
Cancel
</button>
)}
</div>
@@ -348,7 +348,7 @@ function NamedCombosManager() {
</div>
{combo.isDefault && (
<span className="rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
Padrão
Default
</span>
)}
</div>
@@ -364,21 +364,21 @@ function NamedCombosManager() {
))}
</div>
<p className="mt-3 text-xs text-text-muted">
Pacotes de idioma: {combo.languagePacks.join(", ")}
Language packs: {combo.languagePacks.join(", ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={() => editCombo(combo)}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
>
Editar
Edit
</button>
{!combo.isDefault && (
<button
onClick={() => setDefault(combo.id)}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
>
Definir como padrão
Set as default
</button>
)}
{!combo.isDefault && (
@@ -386,7 +386,7 @@ function NamedCombosManager() {
onClick={() => deleteCombo(combo)}
className="rounded-lg border border-danger/40 px-3 py-1.5 text-xs text-danger"
>
Excluir
Delete
</button>
)}
</div>

View File

@@ -5,8 +5,8 @@
// IMPORTANT (hydration): this component deliberately does NOT use `useTranslations`.
// The previous combos redesign failed to hydrate on the production build; the only
// structural difference from the engine pages (which hydrate fine) was a page-level
// `useTranslations("contextCombos")`. To stay on the proven-good path, all strings
// here are hardcoded (pt-BR), exactly like `EngineConfigPage`.
// `useTranslations("contextCombos")`. To stay on the proven-good path, strings here
// remain literal English text, exactly like `EngineConfigPage`.
import { useCallback, useEffect, useState } from "react";
@@ -45,13 +45,13 @@ interface DefaultCombo {
// ── Constants ──────────────────────────────────────────────────────────────────
const MODES: { value: CompressionMode; label: string; hint: string }[] = [
{ value: "off", label: "Off", hint: "Sem compressão" },
{ value: "lite", label: "Lite", hint: "Limpeza rápida" },
{ value: "standard", label: "Standard", hint: "Caveman padrão" },
{ value: "aggressive", label: "Aggressive", hint: "Resumo + aging" },
{ value: "ultra", label: "Ultra", hint: "Poda heurística" },
{ value: "rtk", label: "RTK", hint: "Filtros de tool output" },
{ value: "stacked", label: "Stacked", hint: "Roda as camadas abaixo em sequência" },
{ value: "off", label: "Off", hint: "No compression" },
{ value: "lite", label: "Lite", hint: "Fast cleanup" },
{ value: "standard", label: "Standard", hint: "Standard Caveman" },
{ value: "aggressive", label: "Aggressive", hint: "Summary plus aging" },
{ value: "ultra", label: "Ultra", hint: "Heuristic pruning" },
{ value: "rtk", label: "RTK", hint: "Tool output filters" },
{ value: "stacked", label: "Stacked", hint: "Run the layers below in sequence" },
];
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -161,11 +161,11 @@ export default function CompressionHub() {
});
if (!res.ok) {
setSettings(settings); // revert
setError("Falha ao salvar as configurações.");
setError("Failed to save settings.");
}
} catch {
setSettings(settings);
setError("Falha ao salvar as configurações.");
setError("Failed to save settings.");
}
},
[settings]
@@ -186,12 +186,9 @@ export default function CompressionHub() {
if (enabledNow) {
optimistic = combo.pipeline.filter((s) => s.engine !== engineId);
} else {
const priorityOf = (eid: string) =>
engines.find((e) => e.id === eid)?.stackPriority ?? 50;
const priorityOf = (eid: string) => engines.find((e) => e.id === eid)?.stackPriority ?? 50;
optimistic = [...combo.pipeline];
let insertAt = optimistic.findIndex(
(s) => priorityOf(s.engine) > priorityOf(engineId)
);
let insertAt = optimistic.findIndex((s) => priorityOf(s.engine) > priorityOf(engineId));
if (insertAt < 0) insertAt = optimistic.length;
optimistic.splice(insertAt, 0, { engine: engineId });
}
@@ -206,7 +203,7 @@ export default function CompressionHub() {
});
if (!res.ok) {
setCombo(prev);
setError("Falha ao atualizar a camada.");
setError("Failed to update layer.");
return;
}
const updated = await res.json();
@@ -215,7 +212,7 @@ export default function CompressionHub() {
}
} catch {
setCombo(prev);
setError("Falha ao atualizar a camada.");
setError("Failed to update layer.");
}
},
[combo, engines]
@@ -243,11 +240,11 @@ export default function CompressionHub() {
});
if (!res.ok) {
setCombo(prev);
setError("Falha ao reordenar o pipeline.");
setError("Failed to reorder pipeline.");
}
} catch {
setCombo(prev);
setError("Falha ao reordenar o pipeline.");
setError("Failed to reorder pipeline.");
}
},
[combo]
@@ -257,7 +254,7 @@ export default function CompressionHub() {
if (loading) {
return (
<div className="flex items-center justify-center p-10 text-sm text-text-muted">
Carregando
Loading...
</div>
);
}
@@ -281,7 +278,7 @@ export default function CompressionHub() {
<div>
<h1 className="text-xl font-bold text-text-main">Compression Hub</h1>
<p className="text-sm text-text-muted">
Ligue, configure e ordene as camadas de compressão num lugar.
Enable, configure, and order compression layers in one place.
</p>
</div>
</div>
@@ -290,7 +287,7 @@ export default function CompressionHub() {
onClick={() => setExplainerOpen((v) => !v)}
className="shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg"
>
{explainerOpen ? "Ocultar explicação" : "Como funciona?"}
{explainerOpen ? "Hide explanation" : "How it works"}
</button>
</div>
@@ -302,32 +299,32 @@ export default function CompressionHub() {
{explainerOpen && (
<div className="rounded-lg border border-border bg-bg p-4 text-sm text-text-muted">
<p className="mb-2">
A compressão reduz <strong className="text-text-main">tokens e custo</strong> reescrevendo
o histórico antes de enviar ao provider, preservando o sentido.
Compression reduces <strong className="text-text-main">tokens and cost</strong> by
rewriting history before it is sent to the provider while preserving meaning.
</p>
<ol className="ml-4 list-decimal space-y-1.5">
<li>
<strong className="text-text-main">Token Saver (master)</strong>: precisa estar ligado.
Desligado, nada é comprimido.
<strong className="text-text-main">Token Saver (master)</strong>: must be enabled.
When it is off, nothing is compressed.
</li>
<li>
<strong className="text-text-main">Modo</strong>: define a estratégia. Os modos simples
(Lite/Standard/Aggressive/Ultra/RTK) rodam uma única técnica. O modo{" "}
<strong className="text-text-main">Stacked</strong> roda várias camadas em sequência é
o que usa a lista de camadas abaixo.
<strong className="text-text-main">Mode</strong>: defines the strategy. Simple modes
(Lite/Standard/Aggressive/Ultra/RTK) run one technique.{" "}
<strong className="text-text-main">Stacked</strong> runs multiple layers in sequence
and uses the layer list below.
</li>
<li>
<strong className="text-text-main">Camadas (pipeline)</strong>: no modo Stacked, cada
camada ativa roda na ordem definida, passando o texto comprimido para a próxima
(ex.: Session Dedup RTK Caveman).
<strong className="text-text-main">Layers (pipeline)</strong>: in Stacked mode, each
active layer runs in order and passes the compressed text to the next one (for
example: Session Dedup RTK Caveman).
</li>
<li>
<strong className="text-text-main">Configuração</strong>: cada camada tem liga/desliga e
parâmetros próprios (botão ).
<strong className="text-text-main">Configuration</strong>: each layer has its own
enable switch and parameters (the settings button).
</li>
<li>
<strong className="text-text-main">Combos nomeados</strong>: salve diferentes pipelines
e atribua a combos de roteamento específicos (seção abaixo).
<strong className="text-text-main">Named combos</strong>: save different pipelines and
assign them to specific routing combos in the section below.
</li>
</ol>
</div>
@@ -338,18 +335,18 @@ export default function CompressionHub() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-text-main">Token Saver</p>
<p className="text-xs text-text-muted">Chave geral da compressão.</p>
<p className="text-xs text-text-muted">Master switch for compression.</p>
</div>
<Toggle
checked={enabled}
onChange={() => saveSettings({ enabled: !enabled })}
ariaLabel="Ligar/desligar Token Saver"
ariaLabel="Toggle Token Saver"
/>
</div>
{/* Mode selector */}
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-text-muted">Modo</p>
<p className="text-xs font-medium text-text-muted">Mode</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-7">
{MODES.map((m) => (
<button
@@ -377,19 +374,19 @@ export default function CompressionHub() {
{pipelineActive ? (
<div className="flex items-center gap-2 rounded-lg border border-green-500/40 bg-green-500/5 px-3 py-2 text-xs text-green-500">
<span className="material-symbols-outlined text-[16px]">check_circle</span>
Pipeline de camadas ativo as camadas abaixo rodam em cada requisição.
Layer pipeline is active. The layers below run on each request.
</div>
) : (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-500">
<span className="material-symbols-outlined text-[16px]">info</span>
<span>As camadas abaixo rodam no modo Stacked com o Token Saver ligado.</span>
<span>The layers below only run in Stacked mode with Token Saver enabled.</span>
{!enabled && (
<button
type="button"
onClick={() => saveSettings({ enabled: true })}
className="rounded border border-amber-500/50 px-2 py-0.5 font-medium hover:bg-amber-500/10"
>
Ligar Token Saver
Enable Token Saver
</button>
)}
{enabled && mode !== "stacked" && (
@@ -398,7 +395,7 @@ export default function CompressionHub() {
onClick={() => saveSettings({ defaultMode: "stacked" })}
className="rounded border border-amber-500/50 px-2 py-0.5 font-medium hover:bg-amber-500/10"
>
Usar modo Stacked
Use Stacked mode
</button>
)}
</div>
@@ -409,13 +406,13 @@ export default function CompressionHub() {
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-text-main">
Pipeline ativo <span className="text-text-muted">(ordem de execução)</span>
Active pipeline <span className="text-text-muted">(execution order)</span>
</h2>
<span className="text-xs text-text-muted">{activeSteps.length} camada(s)</span>
<span className="text-xs text-text-muted">{activeSteps.length} layer(s)</span>
</div>
{activeSteps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border px-3 py-4 text-center text-xs text-text-muted">
Nenhuma camada ativa. Ligue uma camada abaixo para montar o pipeline.
No active layers. Enable a layer below to build the pipeline.
</p>
) : (
<ul className="flex flex-col gap-2">
@@ -431,7 +428,7 @@ export default function CompressionHub() {
type="button"
onClick={() => moveStep(index, -1)}
disabled={index === 0}
aria-label="Mover para cima"
aria-label="Move up"
className="text-text-muted hover:text-text-main disabled:opacity-30"
>
<span className="material-symbols-outlined text-[18px]">arrow_upward</span>
@@ -440,7 +437,7 @@ export default function CompressionHub() {
type="button"
onClick={() => moveStep(index, 1)}
disabled={index === activeSteps.length - 1}
aria-label="Mover para baixo"
aria-label="Move down"
className="text-text-muted hover:text-text-main disabled:opacity-30"
>
<span className="material-symbols-outlined text-[18px]">arrow_downward</span>
@@ -466,13 +463,11 @@ export default function CompressionHub() {
</span>
)}
</div>
<p className="truncate text-xs text-text-muted">
{engine?.description ?? ""}
</p>
<p className="truncate text-xs text-text-muted">{engine?.description ?? ""}</p>
</div>
<a
href={enginePagePath(step.engine)}
title="Configurar camada"
title="Configure layer"
className="shrink-0 rounded-lg border border-border px-2 py-1.5 text-text-muted hover:bg-surface hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">settings</span>
@@ -480,7 +475,7 @@ export default function CompressionHub() {
<Toggle
checked
onChange={() => toggleEngine(step.engine)}
ariaLabel={`Desligar ${engine?.name ?? step.engine}`}
ariaLabel={`Disable ${engine?.name ?? step.engine}`}
/>
</li>
);
@@ -492,7 +487,7 @@ export default function CompressionHub() {
{/* ── Inactive layers ── */}
{inactiveEngines.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-text-main">Camadas disponíveis</h2>
<h2 className="text-sm font-semibold text-text-main">Available layers</h2>
<ul className="flex flex-col gap-2">
{inactiveEngines.map((engine) => (
<li
@@ -521,7 +516,7 @@ export default function CompressionHub() {
</div>
<a
href={enginePagePath(engine.id)}
title="Configurar camada"
title="Configure layer"
className="shrink-0 rounded-lg border border-border px-2 py-1.5 text-text-muted hover:bg-surface hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">settings</span>
@@ -529,7 +524,7 @@ export default function CompressionHub() {
<Toggle
checked={false}
onChange={() => toggleEngine(engine.id)}
ariaLabel={`Ligar ${engine.name}`}
ariaLabel={`Enable ${engine.name}`}
/>
</li>
))}

View File

@@ -102,9 +102,7 @@ export default function ConnectionsHeaderToolbar({
"Route bare claude-* model IDs from Claude Code clients through the Claude Code account instead of asking for a provider prefix."
)}
>
<span className="material-symbols-outlined text-[14px] text-orange-500">
alt_route
</span>
<span className="material-symbols-outlined text-[14px] text-orange-500">alt_route</span>
<span>
{providerText(
t,
@@ -162,9 +160,7 @@ export default function ConnectionsHeaderToolbar({
"Set a global Codex service mode, or leave accounts on their individual service-tier setting."
)}
>
<span>
{providerText(t, "providerDetailServiceModeLabel", "Global service mode:")}
</span>
<span>{providerText(t, "providerDetailServiceModeLabel", "Global service mode:")}</span>
<select
value={codexGlobalServiceMode}
onChange={(event) =>
@@ -223,7 +219,9 @@ export default function ConnectionsHeaderToolbar({
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{connections.length > 0 && (
<DistributeProxiesButton
onDistribute={async () => { await handleDistributeProxies(); }}
onDistribute={async () => {
await handleDistributeProxies();
}}
disabled={batchTesting || !!retestingId}
/>
)}
@@ -272,11 +270,7 @@ export default function ConnectionsHeaderToolbar({
</>
) : (
<>
<Button
size="sm"
icon="add"
onClick={() => gateConnectionFlow(openPrimaryAddFlow)}
>
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
{providerSupportsPat ? "Add PAT" : t("add")}
</Button>
{providerId === "qoder" && (
@@ -295,7 +289,7 @@ export default function ConnectionsHeaderToolbar({
icon="menu_book"
onClick={() => onOpenCodexCliGuide()}
>
Codex CLI Guide
{providerText(t, "codexCliGuideButton", "Codex CLI Guide")}
</Button>
)}
{providerId === "codex" && (
@@ -305,7 +299,7 @@ export default function ConnectionsHeaderToolbar({
icon="share"
onClick={() => gateConnectionFlow(openExternalLinkFlow)}
>
Adicionar Externo
{providerText(t, "codexExternalLinkButton", "External Codex link")}
</Button>
)}
{providerId === "codex" && (
@@ -349,11 +343,7 @@ export default function ConnectionsHeaderToolbar({
</>
) : (
connections.length === 0 && (
<Button
size="sm"
icon="add"
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openApiKeyAddFlow)}>
{t("add")}
</Button>
)

View File

@@ -1,6 +1,7 @@
"use client";
import { Modal, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
type ExternalLinkModalProps = {
isOpen: boolean;
@@ -21,16 +22,28 @@ export default function ExternalLinkModal({
copied,
onCopy,
}: ExternalLinkModalProps) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const text = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key as never) ? t(key as never) : fallback;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Adicionar Externo — link do Codex">
<Modal
isOpen={isOpen}
onClose={onClose}
title={text("codexExternalLinkModalTitle", "External Codex link")}
>
<div className="space-y-4">
<p className="text-sm text-text-muted">
Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a
página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso
único, expira em 15 minutos.
{text(
"codexExternalLinkModalDescription",
"Share this single-use link with the person who will authenticate the Codex account. They open it in their own browser, complete the OpenAI login, and the connection is registered here. The link expires in 15 minutes."
)}
</p>
{loading ? (
<p className="text-sm text-text-muted">Gerando link</p>
<p className="text-sm text-text-muted">
{text("codexExternalLinkGenerating", "Generating link...")}
</p>
) : error ? (
<p className="text-sm text-red-500">{error}</p>
) : url ? (
@@ -44,19 +57,22 @@ export default function ExternalLinkModal({
icon="open_in_new"
onClick={() => window.open(url, "_blank", "noopener")}
>
Abrir
{tc("open")}
</Button>
<Button
variant="secondary"
icon="content_copy"
onClick={() => onCopy(url, "extlink")}
>
{copied === "extlink" ? "Copiado" : "Copiar"}
{copied === "extlink" ? tc("copied") : tc("copy")}
</Button>
</div>
<p className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined animate-spin text-[16px]">sync</span>
Aguardando a autenticação no navegador da pessoa esta janela atualiza sozinha.
{text(
"codexExternalLinkWaiting",
"Waiting for browser authentication. This window refreshes automatically."
)}
</p>
</>
) : null}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { providerText } from "../providerPageHelpers";
type UseExternalLinkFlowParams = {
providerId: string;
@@ -12,6 +14,7 @@ export function useExternalLinkFlow({
notify,
fetchConnections,
}: UseExternalLinkFlowParams) {
const t = useTranslations("providers") as any;
const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false);
const [externalLinkUrl, setExternalLinkUrl] = useState("");
const [externalLinkToken, setExternalLinkToken] = useState<string | null>(null);
@@ -19,7 +22,7 @@ export function useExternalLinkFlow({
const [externalLinkError, setExternalLinkError] = useState<string | null>(null);
const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard();
// "Adicionar Externo": generate a single-use public link so a third party can
// External Codex link: generate a single-use public link so a third party can
// complete the Codex device flow in their own browser.
const openExternalLinkFlow = useCallback(async () => {
setExternalLinkModalOpen(true);
@@ -38,14 +41,19 @@ export function useExternalLinkFlow({
setExternalLinkUrl(data.url);
setExternalLinkToken(data.token || null);
} else {
setExternalLinkError(data?.error || "Falha ao gerar o link.");
setExternalLinkError(
data?.error ||
providerText(t, "codexExternalLinkCreateFailed", "Failed to generate the link.")
);
}
} catch {
setExternalLinkError("Não foi possível contatar o servidor.");
setExternalLinkError(
providerText(t, "codexExternalLinkNetworkError", "Could not contact the server.")
);
} finally {
setExternalLinkLoading(false);
}
}, [providerId]);
}, [providerId, t]);
// While the share popup is open, poll the ticket status so the dashboard can
// notify + refresh the connections the moment the external visitor finishes.
@@ -63,14 +71,22 @@ export function useExternalLinkFlow({
if (data?.status === "completed") {
active = false;
clearInterval(interval);
notify.success("Conta Codex conectada pelo link externo.");
notify.success(
providerText(
t,
"codexExternalLinkConnected",
"Codex account connected through the external link."
)
);
fetchConnections();
setExternalLinkModalOpen(false);
setExternalLinkToken(null);
} else if (data?.status === "expired") {
active = false;
clearInterval(interval);
setExternalLinkError("O link expirou sem ser concluído.");
setExternalLinkError(
providerText(t, "codexExternalLinkExpired", "The link expired before completion.")
);
}
} catch {
/* transient network error — keep polling */
@@ -80,7 +96,7 @@ export function useExternalLinkFlow({
active = false;
clearInterval(interval);
};
}, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]);
}, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections, t]);
return {
externalLinkModalOpen,

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import ReactMarkdown, { type Components } from "react-markdown";
import { Modal, Button } from "@/shared/components";
@@ -118,9 +119,13 @@ interface CodexCliGuideModalProps {
}
export default function CodexCliGuideModal({ isOpen, onClose }: CodexCliGuideModalProps) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const text = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key as never) ? t(key as never) : fallback;
useEffect(() => {
if (!isOpen || content) return;
@@ -148,14 +153,16 @@ export default function CodexCliGuideModal({ isOpen, onClose }: CodexCliGuideMod
}, [isOpen, content]);
return (
<Modal isOpen={isOpen} title="Codex CLI Guia de Configuração" onClose={onClose}>
<Modal isOpen={isOpen} title={text("codexCliGuideTitle", "Codex CLI Guide")} onClose={onClose}>
<div className="max-h-[70vh] overflow-y-auto pr-1">
{loading && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<span className="material-symbols-outlined animate-spin text-[28px] text-text-muted/50">
sync
</span>
<p className="text-sm text-text-muted">Carregando guia</p>
<p className="text-sm text-text-muted">
{text("codexCliGuideLoading", "Loading guide...")}
</p>
</div>
)}
{error && (
@@ -163,7 +170,9 @@ export default function CodexCliGuideModal({ isOpen, onClose }: CodexCliGuideMod
<span className="material-symbols-outlined text-[40px] text-red-500/50">
error_outline
</span>
<p className="text-sm">Não foi possível carregar o guia.</p>
<p className="text-sm">
{text("codexCliGuideLoadFailed", "Could not load the guide.")}
</p>
<Button
variant="secondary"
size="sm"
@@ -172,7 +181,7 @@ export default function CodexCliGuideModal({ isOpen, onClose }: CodexCliGuideMod
setError(false);
}}
>
Tentar novamente
{tc("retry")}
</Button>
</div>
)}

View File

@@ -5,10 +5,7 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import Card from "@/shared/components/Card";
import ProviderIcon from "@/shared/components/ProviderIcon";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
import ModelCooldownsCard from "./components/ModelCooldownsCard";
type KnownBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN" | "DEGRADED";
type BreakerState = KnownBreakerState | (string & {});
@@ -615,6 +612,8 @@ export default function RuntimePageClient() {
/>
</div>
<ModelCooldownsCard />
{/* Row 2 — Resilience layers (left, 2/3) + Live Feed (right, 1/3) */}
<div className="grid grid-cols-1 xl:grid-cols-[2fr_1fr] gap-3">
<Card padding="md">

View File

@@ -145,7 +145,10 @@ function ProviderCard({
);
}
export default function ProviderCatalog({ selectedProvider, onSelectProvider }: ProviderCatalogProps) {
export default function ProviderCatalog({
selectedProvider,
onSelectProvider,
}: ProviderCatalogProps) {
const [providers, setProviders] = useState<SearchProviderCatalogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -211,7 +214,7 @@ export default function ProviderCatalog({ selectedProvider, onSelectProvider }:
data-testid={`filter-${kind}`}
>
{kind === "all"
? `Todos (${providers.length})`
? `All (${providers.length})`
: kind === "search"
? `Search (${searchCount})`
: `Fetch (${fetchCount})`}
@@ -221,17 +224,14 @@ export default function ProviderCatalog({ selectedProvider, onSelectProvider }:
{filtered.length === 0 && (
<div className="text-xs text-text-muted py-4 text-center">
Nenhum provider encontrado.{" "}
No provider found.{" "}
<Link href="/dashboard/providers" className="text-accent hover:underline">
Configurar providers
Configure providers
</Link>
</div>
)}
<div
className="grid grid-cols-1 gap-2"
data-testid="provider-catalog-grid"
>
<div className="grid grid-cols-1 gap-2" data-testid="provider-catalog-grid">
{filtered.map((item) => (
<ProviderCard
key={item.id}

View File

@@ -220,14 +220,16 @@ export default function ResultsPanel({
className="flex flex-col items-center justify-center py-20 text-center"
data-testid="no-providers-cta"
>
<span className="text-2xl mb-3" aria-hidden="true">🔌</span>
<p className="text-sm text-text-muted mb-2">Nenhum provider de search ativo</p>
<span className="text-2xl mb-3" aria-hidden="true">
🔌
</span>
<p className="text-sm text-text-muted mb-2">No active search provider</p>
<Link
href="/dashboard/providers"
className="text-accent text-sm hover:underline font-medium"
data-testid="configure-providers-link"
>
Configurar mais providers
Configure more providers
</Link>
</div>
)}

View File

@@ -44,7 +44,7 @@ export default function SearchToolsConfigPane({
>
<div className="p-3 border-b border-border">
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
Configuração
Configuration
</span>
</div>
@@ -69,7 +69,7 @@ export default function SearchToolsConfigPane({
{selectedProvider && (
<div className="text-[10px] text-text-muted space-y-0.5">
<div>
Custo:{" "}
Cost:{" "}
<span className="text-text-main font-medium">
${selectedProvider.costPerQuery.toFixed(4)}/query
</span>
@@ -97,10 +97,10 @@ export default function SearchToolsConfigPane({
}
>
{selectedProvider.status === "configured"
? "Configurado"
? "Configured"
: selectedProvider.status === "rate_limited"
? "Rate limited"
: "Sem credencial"}
: "Missing credential"}
</span>
</div>
</div>
@@ -111,7 +111,7 @@ export default function SearchToolsConfigPane({
{activeTab === "search" && (
<div className="p-3 border-b border-border space-y-2">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
Tipo de busca
Search type
</label>
<Select
value={config.searchType}
@@ -131,7 +131,7 @@ export default function SearchToolsConfigPane({
{activeTab === "scrape" && (
<div className="p-3 border-b border-border space-y-2">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
Formato
Format
</label>
<Select
value={config.fetchFormat}
@@ -141,7 +141,7 @@ export default function SearchToolsConfigPane({
options={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
{ value: "text", label: "Texto" },
{ value: "text", label: "Text" },
]}
className="w-full"
/>
@@ -161,7 +161,7 @@ export default function SearchToolsConfigPane({
{activeTab === "compare" && (
<div className="p-3 border-b border-border">
<div className="text-[10px] text-text-muted">
Selecione até 4 providers na aba Compare para comparar em paralelo.
Select up to 4 providers on the Compare tab to compare them side by side.
</div>
</div>
)}
@@ -177,10 +177,7 @@ export default function SearchToolsConfigPane({
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onConfigChange({ rerankModel: e.target.value })
}
options={[
{ value: "", label: "Nenhum" },
...rerankModels,
]}
options={[{ value: "", label: "None" }, ...rerankModels]}
className="w-full"
/>
</div>
@@ -194,7 +191,7 @@ export default function SearchToolsConfigPane({
aria-expanded={historyExpanded}
>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
Histórico
History
</span>
<span className="text-text-muted text-xs" aria-hidden="true">
{historyExpanded ? "▼" : "▶"}
@@ -202,7 +199,7 @@ export default function SearchToolsConfigPane({
</button>
{historyExpanded && (
<div className="mt-2 text-[10px] text-text-muted">
Histórico disponível na aba Search.
History is available on the Search tab.
</div>
)}
</div>

View File

@@ -35,16 +35,12 @@ function computeOverlap(urlsA: string[], urlsB: string[]): string {
function getBestIndex(values: number[], higherIsBetter = false): number {
if (values.length === 0) return -1;
return higherIsBetter
? values.indexOf(Math.max(...values))
: values.indexOf(Math.min(...values));
return higherIsBetter ? values.indexOf(Math.max(...values)) : values.indexOf(Math.min(...values));
}
function getWorstIndex(values: number[], higherIsBetter = false): number {
if (values.length === 0) return -1;
return higherIsBetter
? values.indexOf(Math.min(...values))
: values.indexOf(Math.max(...values));
return higherIsBetter ? values.indexOf(Math.min(...values)) : values.indexOf(Math.max(...values));
}
/** Build a map of url → count across all compare results to find overlaps */
@@ -61,7 +57,7 @@ function buildUrlCountMap(allResults: CompareResult[]): Map<string, number> {
export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const t = useTranslations("search");
const activeSearchProviders = providers.filter(
(p) => p.kind === "search" && p.status === "configured",
(p) => p.kind === "search" && p.status === "configured"
);
const [query, setQuery] = useState("");
@@ -143,7 +139,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
error: err instanceof Error ? err.message : "Failed",
} as CompareResult;
}
}),
})
);
const resolved = settled.map((s) =>
@@ -158,7 +154,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
urls: [],
results: [],
error: "Request failed",
} as CompareResult),
} as CompareResult)
);
setResults(resolved);
setLoading(false);
@@ -188,15 +184,14 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
className="flex flex-col items-center justify-center flex-1 py-16 text-center"
data-testid="compare-no-providers"
>
<span className="text-3xl mb-3" aria-hidden="true"></span>
<span className="text-3xl mb-3" aria-hidden="true">
</span>
<p className="text-sm text-text-muted mb-2">
Nenhum provider de search ativo configure em Providers
No active search provider. Configure one in Providers.
</p>
<Link
href="/dashboard/providers"
className="text-accent text-sm hover:underline"
>
Configurar providers
<Link href="/dashboard/providers" className="text-accent text-sm hover:underline">
Configure providers
</Link>
</div>
);
@@ -405,8 +400,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
{cr.provider.replace("-search", "")}
</span>
{": "}
{cr.error ? "—" : computeOverlap(baseUrls, cr.urls)}{" "}
in common
{cr.error ? "—" : computeOverlap(baseUrls, cr.urls)} in common
</span>
);
})}
@@ -422,7 +416,9 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
className="flex flex-col items-center justify-center flex-1 py-12 text-center"
data-testid="compare-empty-state"
>
<span className="text-3xl mb-3" aria-hidden="true"></span>
<span className="text-3xl mb-3" aria-hidden="true">
</span>
<p className="text-sm text-text-muted mb-1">
Select providers and enter a query to compare
</p>

View File

@@ -41,7 +41,8 @@ export default function ScrapeTab({ configState, onMetrics }: ScrapeTabProps) {
url: url.trim(),
format: configState.fetchFormat,
full_page: configState.fullPage,
provider: configState.provider && configState.provider !== "auto" ? configState.provider : undefined,
provider:
configState.provider && configState.provider !== "auto" ? configState.provider : undefined,
});
};
@@ -97,17 +98,20 @@ export default function ScrapeTab({ configState, onMetrics }: ScrapeTabProps) {
{/* Options summary */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-text-muted">
<span>
Formato:{" "}
<span className="text-text-main font-medium">{configState.fetchFormat}</span>
Format: <span className="text-text-main font-medium">{configState.fetchFormat}</span>
</span>
<span>
Full page:{" "}
<span className="text-text-main font-medium">{configState.fullPage ? "Sim" : "Não"}</span>
<span className="text-text-main font-medium">
{configState.fullPage ? "Yes" : "No"}
</span>
</span>
<span>
Provider:{" "}
<span className="text-text-main font-medium">
{configState.provider === "auto" || !configState.provider ? "auto" : configState.provider}
{configState.provider === "auto" || !configState.provider
? "auto"
: configState.provider}
</span>
</span>
</div>
@@ -125,10 +129,7 @@ export default function ScrapeTab({ configState, onMetrics }: ScrapeTabProps) {
{/* Loading spinner */}
{loading && (
<div
className="flex items-center justify-center py-12"
data-testid="scrape-loading"
>
<div className="flex items-center justify-center py-12" data-testid="scrape-loading">
<span
className="material-symbols-outlined text-[32px] text-primary animate-spin"
aria-hidden="true"
@@ -139,9 +140,7 @@ export default function ScrapeTab({ configState, onMetrics }: ScrapeTabProps) {
)}
{/* Result */}
{result && !loading && (
<ScrapeResult result={result} latencyMs={latencyMs} />
)}
{result && !loading && <ScrapeResult result={result} latencyMs={latencyMs} />}
{/* Empty state — no result yet */}
{!result && !loading && !error && (
@@ -152,9 +151,7 @@ export default function ScrapeTab({ configState, onMetrics }: ScrapeTabProps) {
<span className="text-3xl mb-3" aria-hidden="true">
📄
</span>
<p className="text-sm text-text-muted mb-1">
Digite uma URL para extrair o conteúdo
</p>
<p className="text-sm text-text-muted mb-1">Digite uma URL para extrair o conteúdo</p>
<p className="text-xs text-text-muted">
Providers disponíveis: Firecrawl, Jina Reader, Tavily.{" "}
<Link href="/dashboard/providers" className="text-accent hover:underline">

View File

@@ -1,47 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { SegmentedControl } from "@/shared/components";
import AppearanceTab from "./components/AppearanceTab";
import ResilienceTab from "./components/ResilienceTab";
import SystemStorageTab from "./components/SystemStorageTab";
export type SettingsTab = "general" | "appearance" | "resilience";
const SETTINGS_TABS: Array<{ value: SettingsTab; label: string; icon: string }> = [
{ value: "general", label: "General", icon: "settings" },
{ value: "appearance", label: "Appearance", icon: "palette" },
{ value: "resilience", label: "Resilience", icon: "health_and_safety" },
];
type SettingsPageClientProps = {
initialTab: SettingsTab;
};
export default function SettingsPageClient({ initialTab }: SettingsPageClientProps) {
const [activeTab, setActiveTab] = useState<SettingsTab>(initialTab);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
const activeLabel = SETTINGS_TABS.find((tab) => tab.value === activeTab)?.label || "General";
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={SETTINGS_TABS}
value={activeTab}
onChange={(value) => setActiveTab(value as SettingsTab)}
aria-label="Settings sections"
className="w-fit"
/>
<div role="tabpanel" aria-label={activeLabel} className="min-w-0">
{activeTab === "general" ? <SystemStorageTab /> : null}
{activeTab === "appearance" ? <AppearanceTab /> : null}
{activeTab === "resilience" ? <ResilienceTab /> : null}
</div>
</div>
);
}

View File

@@ -261,27 +261,40 @@ export default function AppearanceTab() {
disabled={loading}
/>
</div>
</div>
</div>
</div>
<div className="flex items-start justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("endpointTokenSaver", "Token Saver")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"endpointTokenSaverDesc",
"Show the Token Saver panel on the Endpoint page."
)}
</p>
</div>
<Toggle
checked={showTokenSaverOnEndpoint}
onChange={async (checked) => {
await updateSetting(SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY, checked);
}}
disabled={loading}
/>
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">
{getSettingsLabel("endpointPinInformationTitle", "Pin Information to Endpoint Page")}
</p>
<p className="text-sm text-text-muted">
Choose which sections to pin to the top of the Endpoint page.
</p>
</div>
<div className="rounded-lg border border-border bg-surface/40 overflow-hidden">
<div className="flex items-start justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("endpointTokenSaver", "Token Saver")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"endpointTokenSaverDesc",
"Show the Token Saver panel on the Endpoint page."
)}
</p>
</div>
<Toggle
checked={showTokenSaverOnEndpoint}
onChange={async (checked) => {
await updateSetting(SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY, checked);
}}
disabled={loading}
/>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { Card, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function BackgroundDegradationTab() {
@@ -120,19 +120,12 @@ export default function BackgroundDegradationTab() {
"Automatically use cheaper models for background utility tasks"}
</p>
</div>
<button
onClick={() => save({ enabled: !config.enabled })}
<Toggle
checked={config.enabled}
onChange={(enabled) => save({ enabled })}
disabled={loading || saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
config.enabled ? "bg-sky-500" : "bg-white/10"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
config.enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
ariaLabel={t("enableDegradation") || "Enable Background Degradation"}
/>
</div>
{/* Stats */}

View File

@@ -5,7 +5,6 @@ import { Button, Card } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import AutoDisableCard from "./AutoDisableCard";
import ModelCooldownsCard from "./ModelCooldownsCard";
import ModelLockoutCard from "./ModelLockoutCard";
type RequestQueueSettings = {
@@ -802,7 +801,9 @@ function ProviderCooldownCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">{t("resilienceProviderCooldownEnabled")}</div>
<div className="text-xs text-text-muted">
{t("resilienceProviderCooldownEnabled")}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div>
@@ -932,24 +933,7 @@ export default function ResilienceTab() {
return (
<div className="space-y-6">
<ModelCooldownsCard />
<AutoDisableCard />
<Card className="p-6">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-xl text-primary">info</span>
<div>
<h2 className="text-lg font-bold text-text-main">
{tx("resilienceStructureTitle", "Resilience Structure")}
</h2>
<p className="mt-1 text-sm text-text-muted">
{tx(
"resilienceStructureDesc",
"This page only configures behavior. Live breaker state is shown on the Health page. Combo-specific retry and round-robin slot control remain on combo settings."
)}
</p>
</div>
</div>
</Card>
<RequestQueueCard
value={data.requestQueue}

View File

@@ -29,6 +29,8 @@ export default function SystemStorageTab() {
const [clearCacheStatus, setClearCacheStatus] = useState({ type: "", message: "" });
const [purgeLogsLoading, setPurgeLogsLoading] = useState(false);
const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" });
const [manualVacuumLoading, setManualVacuumLoading] = useState(false);
const [manualVacuumStatus, setManualVacuumStatus] = useState({ type: "", message: "" });
const [cleanupBackupsLoading, setCleanupBackupsLoading] = useState(false);
const [cleanupBackupsStatus, setCleanupBackupsStatus] = useState({ type: "", message: "" });
const [saveBackupRetentionLoading, setSaveBackupRetentionLoading] = useState(false);
@@ -216,6 +218,81 @@ export default function SystemStorageTab() {
}
};
const handleClearCache = async () => {
setClearCacheLoading(true);
setClearCacheStatus({ type: "", message: "" });
try {
const res = await fetch("/api/cache", { method: "DELETE" });
const data = await res.json().catch(() => null);
if (res.ok) {
setClearCacheStatus({
type: "success",
message: t("cacheCleared") || "Cache cleared successfully",
});
} else {
setClearCacheStatus({
type: "error",
message: data?.error || t("clearCacheFailed") || "Failed to clear cache",
});
}
} catch {
setClearCacheStatus({ type: "error", message: t("errorOccurred") });
} finally {
setClearCacheLoading(false);
}
};
const handlePurgeExpiredLogs = async () => {
setPurgeLogsLoading(true);
setPurgeLogsStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings/purge-logs", { method: "POST" });
const data = await res.json().catch(() => null);
if (res.ok) {
const deleted = data?.deleted ?? 0;
setPurgeLogsStatus({
type: "success",
message: t("logsDeleted", { count: deleted }) || `Purged ${deleted} expired log(s)`,
});
} else {
setPurgeLogsStatus({
type: "error",
message: data?.error || t("purgeLogsFailed") || "Failed to purge logs",
});
}
} catch {
setPurgeLogsStatus({ type: "error", message: t("errorOccurred") });
} finally {
setPurgeLogsLoading(false);
}
};
const handleManualVacuum = async () => {
setManualVacuumLoading(true);
setManualVacuumStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings/database/vacuum", { method: "POST" });
const data = await res.json().catch(() => null);
if (res.ok && data?.success !== false) {
setManualVacuumStatus({
type: "success",
message: data?.message || "VACUUM completed",
});
await loadDatabaseSettings();
await loadStorageHealth();
} else {
setManualVacuumStatus({
type: "error",
message: data?.error || "VACUUM failed",
});
}
} catch {
setManualVacuumStatus({ type: "error", message: t("errorOccurred") });
} finally {
setManualVacuumLoading(false);
}
};
const handleManualBackup = async () => {
setManualBackupLoading(true);
setManualBackupStatus({ type: "", message: "" });
@@ -579,98 +656,6 @@ export default function SystemStorageTab() {
</div>
</div>
{/* Logs Settings Section */}
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium text-text-main">{t("logsSettingsTitle")}</p>
<p className="text-xs text-text-muted">
Configure detailed logging and call log pipeline settings
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("detailedLogsLabel")}</span>
<p className="text-xs text-text-muted">{t("detailedLogsDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("callLogPipelineLabel")}</span>
<p className="text-xs text-text-muted">{t("callLogPipelineDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("maxDetailSizeLabel")}</span>
<p className="text-xs text-text-muted">{t("maxDetailSizeDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("ringBufferSizeLabel")}</span>
<p className="text-xs text-text-muted">{t("ringBufferSizeDesc")}</p>
</label>
</div>
</div>
</div>
{/* Cache Settings Section */}
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium text-text-main">{t("cacheSettings")}</p>
<p className="text-xs text-text-muted">
Configure semantic and prompt caching behavior
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("semanticCacheEnabledLabel")}</span>
<p className="text-xs text-text-muted">
Enable semantic caching for similar requests
</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("semanticCacheMaxSizeLabel")}</span>
<p className="text-xs text-text-muted">{t("semanticCacheMaxSizeDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("semanticCacheTTLLabel")}</span>
<p className="text-xs text-text-muted">
Time-to-live for semantic cache entries (ms)
</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("promptCacheEnabledLabel")}</span>
<p className="text-xs text-text-muted">{t("promptCacheEnabledDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("promptCacheStrategyLabel")}</span>
<p className="text-xs text-text-muted">{t("promptCacheStrategyDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">{t("alwaysPreserveClientCacheLabel")}</span>
<p className="text-xs text-text-muted">{t("alwaysPreserveClientCacheDesc")}</p>
</label>
</div>
</div>
</div>
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
@@ -888,111 +873,62 @@ export default function SystemStorageTab() {
</span>
<p className="font-medium">{t("maintenance") || "Maintenance"}</p>
</div>
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
loading={clearCacheLoading}
onClick={async () => {
setClearCacheLoading(true);
setClearCacheStatus({ type: "", message: "" });
try {
const res = await fetch("/api/cache", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setClearCacheStatus({
type: "success",
message: t("cacheCleared") || "Cache cleared successfully",
});
} else {
setClearCacheStatus({
type: "error",
message: data.error || t("clearCacheFailed") || "Failed to clear cache",
});
}
} catch {
setClearCacheStatus({ type: "error", message: t("errorOccurred") });
} finally {
setClearCacheLoading(false);
}
}}
onClick={handleClearCache}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
delete_sweep
</span>
{t("clearCache") || "Clear Cache"}
</Button>
{clearCacheStatus.message && (
<div
className={`p-3 rounded-lg text-sm ${
clearCacheStatus.type === "success"
? "bg-green-500/10 text-green-500 border border-green-500/20"
: "bg-red-500/10 text-red-500 border border-red-500/20"
}`}
role="alert"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{clearCacheStatus.type === "success" ? "check_circle" : "error"}
</span>
{clearCacheStatus.message}
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
loading={purgeLogsLoading}
onClick={async () => {
setPurgeLogsLoading(true);
setPurgeLogsStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings/purge-logs", { method: "POST" });
const data = await res.json();
if (res.ok) {
setPurgeLogsStatus({
type: "success",
message:
t("logsDeleted", { count: data.deleted }) ||
`Purged ${data.deleted} expired log(s)`,
});
} else {
setPurgeLogsStatus({
type: "error",
message: data.error || t("purgeLogsFailed") || "Failed to purge logs",
});
}
} catch {
setPurgeLogsStatus({ type: "error", message: t("errorOccurred") });
} finally {
setPurgeLogsLoading(false);
}
}}
onClick={handlePurgeExpiredLogs}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
auto_delete
</span>
{t("purgeExpiredLogs") || "Purge Expired Logs"}
</Button>
{purgeLogsStatus.message && (
<div
className={`p-3 rounded-lg text-sm ${
purgeLogsStatus.type === "success"
? "bg-green-500/10 text-green-500 border border-green-500/20"
: "bg-red-500/10 text-red-500 border border-red-500/20"
}`}
role="alert"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{purgeLogsStatus.type === "success" ? "check_circle" : "error"}
</span>
{purgeLogsStatus.message}
<Button
variant="outline"
size="sm"
loading={manualVacuumLoading}
onClick={handleManualVacuum}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
cleaning_services
</span>
Manual VACUUM
</Button>
</div>
<div className="mt-3 flex flex-col gap-2">
{[clearCacheStatus, purgeLogsStatus, manualVacuumStatus]
.filter((status) => status.message)
.map((status, index) => (
<div
key={`${status.type}-${index}`}
className={`p-3 rounded-lg text-sm ${
status.type === "success"
? "bg-green-500/10 text-green-500 border border-green-500/20"
: "bg-red-500/10 text-red-500 border border-red-500/20"
}`}
role="alert"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{status.type === "success" ? "check_circle" : "error"}
</span>
{status.message}
</div>
</div>
</div>
)}
))}
</div>
</div>

View File

@@ -1,22 +1,30 @@
import SettingsPageClient, { type SettingsTab } from "./SettingsPageClient";
import { redirect } from "next/navigation";
const LEGACY_TAB_ROUTES: Record<string, SettingsTab> = {
appearance: "appearance",
general: "general",
resilience: "resilience",
const LEGACY_TAB_ROUTES: Record<string, string> = {
advanced: "/dashboard/settings/advanced",
ai: "/dashboard/settings/ai",
appearance: "/dashboard/settings/appearance",
featureFlags: "/dashboard/settings/feature-flags",
"feature-flags": "/dashboard/settings/feature-flags",
general: "/dashboard/settings/general",
resilience: "/dashboard/settings/resilience",
routing: "/dashboard/settings/routing",
security: "/dashboard/settings/security",
sidebar: "/dashboard/settings/sidebar",
};
type SettingsPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
function normalizeTab(value: string | undefined): SettingsTab {
return value && value in LEGACY_TAB_ROUTES ? LEGACY_TAB_ROUTES[value] : "general";
function resolveSettingsRoute(value: string | undefined): string {
return value
? LEGACY_TAB_ROUTES[value] || "/dashboard/settings/general"
: "/dashboard/settings/general";
}
export default async function SettingsPage({ searchParams }: SettingsPageProps) {
const params = searchParams ? await searchParams : {};
const tab = Array.isArray(params.tab) ? params.tab[0] : params.tab;
return <SettingsPageClient initialTab={normalizeTab(tab)} />;
redirect(resolveSettingsRoute(tab));
}

View File

@@ -7,7 +7,9 @@ export default function SettingsResiliencePage() {
const t = useTranslations("settings");
return (
<div className="space-y-6">
<p className="text-sm text-text-muted">{t("resilienceSettingsIntro")}</p>
<p className="text-sm text-text-muted">
{t("resilienceSettingsIntro")} {t("resilienceStructureDesc")}
</p>
<ResilienceTab />
</div>
);

View File

@@ -12,9 +12,9 @@ export default function SettingsRoutingPage() {
return (
<div className="space-y-6">
<p className="text-sm text-text-muted">{t("routingSettingsIntro")}</p>
<ComboDefaultsTab />
<RoutingTab />
<ModelRoutingSection />
<ComboDefaultsTab />
<ModelAliasesUnified />
<BackgroundDegradationTab />
</div>

View File

@@ -35,10 +35,7 @@ interface StatCardProps {
color: "blue" | "green" | "red" | "purple" | "amber" | "cyan";
}
const COLOR_MAP: Record<
StatCardProps["color"],
{ shell: string; icon: string }
> = {
const COLOR_MAP: Record<StatCardProps["color"], { shell: string; icon: string }> = {
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
green: { shell: "bg-green-500/10", icon: "text-green-500" },
red: { shell: "bg-red-500/10", icon: "text-red-500" },
@@ -92,7 +89,7 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
return fallback;
}
},
[t],
[t]
);
const [events, setEvents] = useState<TranslationEvent[]>([]);
@@ -136,9 +133,8 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
const successCount = events.filter((e) => e.status === "success").length;
const errorCount = events.filter((e) => e.status === "error").length;
const comboCount = events.filter((e) => e.isComboRouted).length;
const uniqueEndpoints = new Set(
events.map((e) => e.routeEndpoint ?? e.endpoint).filter(Boolean),
).size;
const uniqueEndpoints = new Set(events.map((e) => e.routeEndpoint ?? e.endpoint).filter(Boolean))
.size;
const avgLatency =
events.length > 0
? Math.round(events.reduce((sum, e) => sum + (e.latency ?? 0), 0) / events.length)
@@ -160,7 +156,7 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
<p>
{translateOrFallback(
"monitorOriginHint",
"Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real.",
"Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real."
)}
</p>
</div>
@@ -269,12 +265,12 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
<div data-testid="monitor-empty-state">
<EmptyState
icon="📊"
title={translateOrFallback("noTranslations", "Nenhuma tradução ainda")}
title={translateOrFallback("noTranslations", "No translations yet")}
description={translateOrFallback(
"monitorEmptyCta",
"Volte para a aba Translate e envie um request — ele aparecerá aqui.",
"Go back to the Translate tab and send a request. It will appear here."
)}
actionLabel={translateOrFallback("monitorOpenTranslateButton", "Ir para Translate")}
actionLabel={translateOrFallback("monitorOpenTranslateButton", "Go to Translate")}
onAction={onGoToTranslate ?? null}
/>
</div>
@@ -284,9 +280,7 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">{t("time")}</th>
<th className="pb-2 pr-4">
{translateOrFallback("routeDetails", "Route")}
</th>
<th className="pb-2 pr-4">{translateOrFallback("routeDetails", "Route")}</th>
<th className="pb-2 pr-4">{t("source")}</th>
<th className="pb-2 pr-4">{t("target")}</th>
<th className="pb-2 pr-4">{t("model")}</th>

View File

@@ -12,10 +12,7 @@ interface FlowNodeProps {
tooltipContent?: string;
}
const COLOR_MAP: Record<
FlowNodeProps["color"],
{ border: string; bg: string; text: string }
> = {
const COLOR_MAP: Record<FlowNodeProps["color"], { border: string; bg: string; text: string }> = {
primary: { border: "border-primary/30", bg: "bg-primary/5", text: "text-primary" },
orange: { border: "border-orange-500/30", bg: "bg-orange-500/5", text: "text-orange-500" },
blue: { border: "border-blue-500/30", bg: "bg-blue-500/5", text: "text-blue-500" },
@@ -66,9 +63,7 @@ function FlowArrow({ label }: { label?: string }) {
>
arrow_forward
</span>
{label && (
<span className="text-[9px] uppercase tracking-wide mt-0.5">{label}</span>
)}
{label && <span className="text-[9px] uppercase tracking-wide mt-0.5">{label}</span>}
</div>
);
}
@@ -84,7 +79,7 @@ export default function TranslateFlowDiagram() {
return fallback;
}
},
[t],
[t]
);
return (
@@ -92,18 +87,18 @@ export default function TranslateFlowDiagram() {
<FlowNode
icon="smart_toy"
color="primary"
title={tr("conceptDiagramAppLabel", "Sua app")}
title={tr("conceptDiagramAppLabel", "Your app")}
example={tr("conceptDiagramExampleApp", "ex: SDK Anthropic")}
/>
<FlowArrow label={tr("conceptDiagramArrow1", "fala")} />
<FlowArrow label={tr("conceptDiagramArrow1", "speaks")} />
<FlowNode
icon="psychology"
color="orange"
title={tr("conceptDiagramSourceLabel", "Formato origem")}
title={tr("conceptDiagramSourceLabel", "Source format")}
example={tr("conceptDiagramExampleSource", "claude")}
tooltipContent={tr(
"conceptDiagramSourceTooltip",
"Formato do protocolo de API que sua app fala (ex: Anthropic Messages, OpenAI Chat Completions, Gemini).",
"The API protocol format your app speaks (for example Anthropic Messages, OpenAI Chat Completions, or Gemini)."
)}
/>
<FlowArrow label={tr("conceptDiagramArrow2", "Translator")} />
@@ -111,21 +106,21 @@ export default function TranslateFlowDiagram() {
icon="hub"
color="emerald"
title={tr("conceptDiagramHubLabel", "OpenAI (hub)")}
example={tr("conceptDiagramExampleHub", "formato pivô")}
example={tr("conceptDiagramExampleHub", "pivot format")}
tooltipContent={tr(
"conceptDiagramHubTooltip",
"Hub intermediário usado pelo translator para converter entre formatos não-compatíveis diretamente. Todos os formatos passam por OpenAI como pivô.",
"Intermediate hub used by the translator to convert between formats that are not directly compatible. All formats pass through OpenAI as the pivot."
)}
/>
<FlowArrow label={tr("conceptDiagramArrow3", "→")} />
<FlowNode
icon="auto_awesome"
color="blue"
title={tr("conceptDiagramTargetLabel", "Provider destino")}
title={tr("conceptDiagramTargetLabel", "Target provider")}
example={tr("conceptDiagramExampleTarget", "Gemini")}
tooltipContent={tr(
"conceptDiagramTargetTooltip",
"Provider conectado em OmniRoute que vai responder de verdade (ex: Google Gemini, Anthropic, etc).",
"The provider connected in OmniRoute that actually responds, such as Google Gemini or Anthropic."
)}
/>
</div>

View File

@@ -18,7 +18,7 @@ export default function TranslatorConceptCard() {
return fallback;
}
},
[t],
[t]
);
return (
@@ -35,13 +35,13 @@ export default function TranslatorConceptCard() {
<h2 className="text-sm font-semibold text-text-main mb-1">
{tr(
"conceptHeadline",
'Sua app fala o "idioma" de uma API. O Translator converte para usar outro provider.',
'Your app speaks one API "language". Translator converts it to use another provider.'
)}
</h2>
<p className="text-xs text-text-muted">
{tr(
"friendlySubtitle",
"Use sua app existente com qualquer provider — sem reescrever código.",
"Use your existing app with any provider without rewriting code."
)}
</p>
</div>
@@ -56,7 +56,7 @@ export default function TranslatorConceptCard() {
aria-controls="translator-concept-how-it-works"
className="flex items-center gap-2 text-xs font-medium text-primary hover:text-primary/80 transition-colors w-full justify-start py-1 rounded"
>
<span>{tr("conceptHowItWorksToggle", "Como funciona")}</span>
<span>{tr("conceptHowItWorksToggle", "How it works")}</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{open ? "expand_less" : "expand_more"}
</span>
@@ -69,7 +69,7 @@ export default function TranslatorConceptCard() {
>
{tr(
"conceptHowItWorksBody",
"Sua app envia um pedido no formato dela. O Translator detecta o formato, converte via OpenAI como hub intermediário (ou direto, quando há tradutor direto disponível), envia ao provider escolhido e devolve a resposta convertida de volta no formato da sua app.",
"Your app sends a request in its own format. Translator detects that format, converts through OpenAI as an intermediate hub (or directly when a direct translator is available), sends it to the selected provider, and converts the response back to your app's format."
)}
</div>
)}

View File

@@ -160,7 +160,7 @@ function TestBenchContent() {
const latency = Date.now() - start;
if (!sendRes.ok) {
const errData = await sendRes.json().catch(() => ({})) as { error?: string };
const errData = (await sendRes.json().catch(() => ({}))) as { error?: string };
setResults((prev) => ({
...prev,
[scenario.id]: {
@@ -425,10 +425,7 @@ function TestBenchContent() {
);
}
export default function TestBenchAccordion({
forceOpen,
onOpenChange,
}: TestBenchAccordionProps) {
export default function TestBenchAccordion({ forceOpen, onOpenChange }: TestBenchAccordionProps) {
const t = useTranslations("translator");
const translateOrFallback = (key: string, fallback: string): string => {
@@ -462,10 +459,10 @@ export default function TestBenchAccordion({
return (
<Collapsible
title={translateOrFallback("advancedTestBenchTitle", "Test Bench (8 cenários)")}
title={translateOrFallback("advancedTestBenchTitle", "Test Bench (8 scenarios)")}
subtitle={translateOrFallback(
"advancedTestBenchSubtitle",
"Roda todos os cenários e reporta pass/fail + compatibilidade %.",
"Runs every scenario and reports pass/fail plus compatibility percentage."
)}
icon="science"
defaultOpen={forceOpen ?? false}

View File

@@ -4373,6 +4373,19 @@
"codexAuthApplyFailed": "Failed to apply Codex auth.json locally",
"codexAuthExported": "Codex auth.json exported",
"codexAuthExportFailed": "Failed to export Codex auth.json",
"codexCliGuideButton": "Codex CLI Guide",
"codexCliGuideTitle": "Codex CLI Guide",
"codexCliGuideLoading": "Loading guide...",
"codexCliGuideLoadFailed": "Could not load the guide.",
"codexExternalLinkButton": "External Codex link",
"codexExternalLinkModalTitle": "External Codex link",
"codexExternalLinkModalDescription": "Share this single-use link with the person who will authenticate the Codex account. They open it in their own browser, complete the OpenAI login, and the connection is registered here. The link expires in 15 minutes.",
"codexExternalLinkGenerating": "Generating link...",
"codexExternalLinkWaiting": "Waiting for browser authentication. This window refreshes automatically.",
"codexExternalLinkCreateFailed": "Failed to generate the link.",
"codexExternalLinkNetworkError": "Could not contact the server.",
"codexExternalLinkConnected": "Codex account connected through the external link.",
"codexExternalLinkExpired": "The link expired before completion.",
"importCodexAuth": "Import auth",
"codexImportModalTitle": "Import Codex Auth",
"codexImportTabSingle": "Single",
@@ -4905,6 +4918,7 @@
"homeQuickStartDesc": "Show the Quick Start panel on the Home page.",
"homeProviderTopology": "Provider Topology",
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
"endpointPinInformationTitle": "Pin Information to Endpoint Page",
"endpointTokenSaver": "Token Saver",
"endpointTokenSaverDesc": "Show the Token Saver panel on the Endpoint page.",
"accountEmailVisibility": "Account email visibility",

View File

@@ -16,6 +16,14 @@ import {
truncateUrl,
} from "@/shared/utils/formatting";
import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
import {
LOG_TABLE_CLASS,
LOG_TABLE_HEAD_CLASS,
LOG_TABLE_HEADER_BG_STYLE,
LOG_TABLE_HEADER_CELL_CLASS,
LOG_TABLE_HEADER_CELL_RIGHT_CLASS,
LOG_TABLE_ROW_CLASS,
} from "./logTableStyles";
const PROXY_COLUMN_KEYS = [
"status",
@@ -396,64 +404,38 @@ export default function ProxyLogger() {
) : sortedLogs.length === 0 ? (
<div className="p-8 text-center text-text-muted">{t("noMatchingLogs")}</div>
) : (
<table className="w-full text-left border-collapse text-xs">
<thead
className="sticky top-0 z-10"
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
>
<tr
className="border-b border-border"
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
>
<table className={LOG_TABLE_CLASS}>
<thead className={LOG_TABLE_HEAD_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
<tr className={LOG_TABLE_ROW_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
{visibleColumns.status && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colStatus")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colStatus")}</th>
)}
{visibleColumns.proxy && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colProxy")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colProxy")}</th>
)}
{visibleColumns.tls && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colTls")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colTls")}</th>
)}
{visibleColumns.type && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colType")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colType")}</th>
)}
{visibleColumns.level && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colLevel")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colLevel")}</th>
)}
{visibleColumns.provider && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colProvider")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colProvider")}</th>
)}
{visibleColumns.target && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colTarget")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colTarget")}</th>
)}
{visibleColumns.latency && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
{t("colLatency")}
</th>
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("colLatency")}</th>
)}
{visibleColumns.ip && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("colClientIp")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("colClientIp")}</th>
)}
{visibleColumns.time && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
{t("colTime")}
</th>
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("colTime")}</th>
)}
</tr>
</thead>

View File

@@ -39,6 +39,14 @@ import {
readSavedRefreshIntervalSec,
writeSavedRefreshIntervalSec,
} from "./requestLoggerPreferences";
import {
LOG_TABLE_CLASS,
LOG_TABLE_HEAD_CLASS,
LOG_TABLE_HEADER_BG_STYLE,
LOG_TABLE_HEADER_CELL_CLASS,
LOG_TABLE_HEADER_CELL_RIGHT_CLASS,
LOG_TABLE_ROW_CLASS,
} from "./logTableStyles";
// Number of call-log rows fetched per page. The viewer grows its window by this
// amount on "Load more" / infinite scroll so users can browse past the first
@@ -935,79 +943,47 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
) : sortedLogs.length === 0 ? (
<div className="p-8 text-center text-text-muted">{t("noMatchingLogs")}</div>
) : (
<table className="w-full text-left border-collapse text-xs">
<thead
className="sticky top-0 z-10"
style={{ backgroundColor: "var(--color-bg, #fff)" }}
>
<tr
className="border-b border-border"
style={{ backgroundColor: "var(--color-bg, #fff)" }}
>
<table className={LOG_TABLE_CLASS}>
<thead className={LOG_TABLE_HEAD_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
<tr className={LOG_TABLE_ROW_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
{visibleColumns.status && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.status")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.status")}</th>
)}
{visibleColumns.cacheSource && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.cacheSource")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.cacheSource")}</th>
)}
{visibleColumns.model && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.model")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.model")}</th>
)}
{visibleColumns.requestedModel && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.requested")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.requested")}</th>
)}
{visibleColumns.provider && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.provider")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.provider")}</th>
)}
{visibleColumns.protocol && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.protocol")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.protocol")}</th>
)}
{visibleColumns.account && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.account")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.account")}</th>
)}
{visibleColumns.apiKey && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.apiKey")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.apiKey")}</th>
)}
{visibleColumns.combo && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
{t("columns.combo")}
</th>
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{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">
{t("columns.tokens")}
</th>
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.tokens")}</th>
)}
{visibleColumns.tps && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
{t("columns.tps")}
</th>
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.tps")}</th>
)}
{visibleColumns.duration && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
{t("columns.duration")}
</th>
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{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">
{t("columns.time")}
</th>
<th className={LOG_TABLE_HEADER_CELL_RIGHT_CLASS}>{t("columns.time")}</th>
)}
</tr>
</thead>

View File

@@ -242,9 +242,11 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
<div className="flex flex-col gap-2 rounded-lg border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-text">Ativar camada</span>
<span className="text-sm font-medium text-text">Enable layer</span>
<span className="text-xs text-text-muted">
{enabled ? "Esta camada está ativa no pipeline padrão." : "Esta camada está inativa."}
{enabled
? "This layer is active in the default pipeline."
: "This layer is inactive."}
</span>
</div>
<input
@@ -253,13 +255,12 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
checked={enabled}
onChange={handleToggle}
className="h-4 w-4 accent-primary cursor-pointer"
aria-label="Ativar camada"
aria-label="Enable layer"
/>
</div>
{toggleError && <p className="text-xs text-destructive">{toggleError}</p>}
<p className="text-xs text-text-muted" data-testid="stacked-mode-notice">
As camadas ativadas rodam quando a compressão está no modo &quot;stacked&quot;. Configure
em{" "}
Enabled layers run when compression is in &quot;stacked&quot; mode. Configure it in{" "}
<a href="/dashboard/context/settings" className="underline hover:text-text">
Compression Settings
</a>
@@ -269,7 +270,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Config form ── */}
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-text">Configuração</h2>
<h2 className="text-sm font-semibold text-text">Configuration</h2>
<EngineConfigForm
schema={engine.configSchema}
value={configState}
@@ -281,7 +282,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
disabled={saving}
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{saving ? "Salvando…" : "Salvar"}
{saving ? "Saving..." : "Save"}
</button>
{saveError && <p className="text-xs text-destructive">{saveError}</p>}
</div>
@@ -302,20 +303,20 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
disabled={previewLoading}
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{previewLoading ? "Processando…" : "Preview"}
{previewLoading ? "Processing..." : "Preview"}
</button>
</div>
{previewError && <p className="text-xs text-destructive">{previewError}</p>}
{preview && (
<div className="flex gap-4 text-sm pt-1">
<span className="text-text-muted">
Tokens originais: <strong className="text-text">{preview.originalTokens}</strong>
Original tokens: <strong className="text-text">{preview.originalTokens}</strong>
</span>
<span className="text-text-muted">
Tokens comprimidos: <strong className="text-text">{preview.compressedTokens}</strong>
Compressed tokens: <strong className="text-text">{preview.compressedTokens}</strong>
</span>
<span className="text-text-muted">
Economia: <strong className="text-primary">{preview.savingsPct.toFixed(1)}%</strong>
Savings: <strong className="text-primary">{preview.savingsPct.toFixed(1)}%</strong>
</span>
</div>
)}
@@ -323,17 +324,20 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Analytics strip ── */}
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-text">Últimos 7 dias</h2>
<h2 className="text-sm font-semibold text-text">Last 7 days</h2>
{analytics && analytics.runs === 0 ? (
<p className="text-sm text-text-muted">Sem dados ainda / No data yet</p>
<p className="text-sm text-text-muted">No data yet</p>
) : analytics ? (
<div className="grid grid-cols-3 gap-3">
<StatCard label="Execuções" value={analytics.runs.toLocaleString()} />
<StatCard label="Tokens economizados" value={analytics.tokensSaved.toLocaleString()} />
<StatCard label="Economia média" value={`${analytics.avgSavingsPercent.toFixed(1)}%`} />
<StatCard label="Runs" value={analytics.runs.toLocaleString()} />
<StatCard label="Tokens saved" value={analytics.tokensSaved.toLocaleString()} />
<StatCard
label="Average savings"
value={`${analytics.avgSavingsPercent.toFixed(1)}%`}
/>
</div>
) : (
<p className="text-sm text-text-muted">Sem dados ainda / No data yet</p>
<p className="text-sm text-text-muted">No data yet</p>
)}
</div>
</div>

View File

@@ -0,0 +1,7 @@
export const LOG_TABLE_CLASS = "w-full text-left border-collapse text-xs";
export const LOG_TABLE_HEAD_CLASS = "sticky top-0 z-10";
export const LOG_TABLE_ROW_CLASS = "border-b border-border";
export const LOG_TABLE_HEADER_CELL_CLASS =
"px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]";
export const LOG_TABLE_HEADER_CELL_RIGHT_CLASS = `${LOG_TABLE_HEADER_CELL_CLASS} text-right`;
export const LOG_TABLE_HEADER_BG_STYLE = { backgroundColor: "var(--color-bg, #fff)" } as const;

View File

@@ -85,7 +85,6 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"batch",
"batch-files",
// Configuration
"settings",
"settings-general",
"settings-appearance",
"settings-ai",
@@ -755,13 +754,6 @@ const BATCH_GROUP: SidebarItemGroup = {
};
const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [
{
id: "settings",
href: "/dashboard/settings",
i18nKey: "settings",
subtitleKey: "settingsSubtitle",
icon: "settings",
},
{
id: "settings-general",
href: "/dashboard/settings/general",
@@ -955,7 +947,7 @@ const MINIMAL_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"costs",
"logs",
"health",
"settings",
"settings-general",
"settings-sidebar",
"docs",
"changelog",
@@ -988,7 +980,7 @@ const DEVELOPER_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"skills",
"mcp",
"a2a",
"settings",
"settings-general",
"settings-routing",
"settings-resilience",
"settings-sidebar",
@@ -1019,7 +1011,6 @@ const ADMIN_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"audit",
"audit-mcp",
"audit-a2a",
"settings",
"settings-general",
"settings-routing",
"settings-resilience",

View File

@@ -45,19 +45,13 @@ test("endpoint page keeps APIs, MCP, and A2A as in-page tabs", () => {
assert.ok(source.includes('activeEndpointTab === "a2a" ? <A2ADashboardPage /> : null'));
});
test("settings root renders the General, Appearance, and Resilience tab panel", () => {
test("settings root redirects to section pages instead of rendering a tab shell", () => {
const pageSource = readSource("src/app/(dashboard)/dashboard/settings/page.tsx");
const clientSource = readSource("src/app/(dashboard)/dashboard/settings/SettingsPageClient.tsx");
assert.ok(pageSource.includes("return <SettingsPageClient initialTab={normalizeTab(tab)} />"));
assert.ok(
clientSource.includes('export type SettingsTab = "general" | "appearance" | "resilience"')
);
for (const label of ["General", "Appearance", "Resilience"]) {
assert.ok(clientSource.includes('label: "' + label + '"'));
}
assert.ok(clientSource.includes('role="tabpanel"'));
assert.ok(clientSource.includes("aria-label={activeLabel}"));
assert.ok(pageSource.includes('import { redirect } from "next/navigation"'));
assert.ok(pageSource.includes('general: "/dashboard/settings/general"'));
assert.ok(pageSource.includes('resilience: "/dashboard/settings/resilience"'));
assert.ok(pageSource.includes("redirect(resolveSettingsRoute(tab))"));
});
test("provider limit status chips use English fallback labels", () => {

View File

@@ -105,6 +105,11 @@ test("sidebar visibility drops stale entries from saved settings", () => {
false
);
assert.equal((allSidebarItemIds as string[]).includes("auto-combo"), false);
assert.equal(
(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("settings"),
false
);
assert.equal((allSidebarItemIds as string[]).includes("settings"), false);
assert.deepEqual(sidebarVisibility.normalizeHiddenSidebarItems(["auto-combo" as any, "logs"]), [
"logs",
]);
@@ -156,9 +161,15 @@ test("legacy dashboard routes redirect to their consolidated surfaces", async ()
join(repoRoot, "src/app/(dashboard)/dashboard/usage/page.tsx"),
"utf8"
);
const settingsPage = await readFile(
join(repoRoot, "src/app/(dashboard)/dashboard/settings/page.tsx"),
"utf8"
);
assert.match(autoComboPage, /redirect\("\/dashboard\/combos\?filter=intelligent"\)/);
assert.match(usagePage, /redirect\("\/dashboard\/logs"\)/);
assert.match(settingsPage, /redirect\(resolveSettingsRoute\(tab\)\)/);
assert.match(settingsPage, /\/dashboard\/settings\/general/);
const compressionPage = await readFile(
join(repoRoot, "src/app/(dashboard)/dashboard/compression/page.tsx"),

View File

@@ -0,0 +1,80 @@
// @vitest-environment jsdom
// Regression for #3973: the System Storage tab gained a "Manual VACUUM" button that
// POSTs to /api/settings/database/vacuum and surfaces the result. This guards that the
// button is rendered and wired to the vacuum endpoint (the only net-new runtime behavior
// in the PR beyond the structurally-tested settings-shell redirect).
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
import SystemStorageTab from "@/app/(dashboard)/dashboard/settings/components/SystemStorageTab";
let container: HTMLDivElement;
let root: Root;
const fetchCalls: Array<{ url: string; method: string }> = [];
beforeEach(() => {
fetchCalls.length = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const method = (init?.method || "GET").toUpperCase();
fetchCalls.push({ url, method });
// Keep the heavy DB-settings form unrendered (its shape is irrelevant to this
// test): a non-ok GET leaves dbSettings null, so the `!dbSettingsLoading &&
// dbSettings` form block is skipped while the Maintenance card stays rendered.
if (method === "GET" && /\/api\/settings\/database$/.test(url)) {
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) } as unknown as Response);
}
return Promise.resolve({
ok: true,
status: 200,
// Permissive body covering every loader; vacuum returns a success payload.
json: async () => ({ success: true, message: "VACUUM completed", backups: [] }),
} as unknown as Response);
})
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("SystemStorageTab — Manual VACUUM button (#3973)", () => {
it("POSTs to /api/settings/database/vacuum when the Manual VACUUM button is clicked", async () => {
await act(async () => {
root.render(<SystemStorageTab />);
});
// Let mount-time loaders settle.
await act(async () => {
await Promise.resolve();
});
const vacuumButton = Array.from(container.querySelectorAll("button")).find((b) =>
(b.textContent || "").includes("Manual VACUUM")
);
expect(vacuumButton, "Manual VACUUM button should be rendered").toBeTruthy();
await act(async () => {
vacuumButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const vacuumCall = fetchCalls.find((c) => c.url.includes("/api/settings/database/vacuum"));
expect(vacuumCall, "a POST to the vacuum endpoint should be issued").toBeTruthy();
expect(vacuumCall!.method).toBe("POST");
});
});