mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 só 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 já 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 só 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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "stacked". Configure
|
||||
em{" "}
|
||||
Enabled layers run when compression is in "stacked" 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>
|
||||
|
||||
7
src/shared/components/logTableStyles.ts
Normal file
7
src/shared/components/logTableStyles.ts
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
80
tests/unit/ui/system-storage-manual-vacuum.test.tsx
Normal file
80
tests/unit/ui/system-storage-manual-vacuum.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user