diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 3f2a7de96b..5dc9e741e7 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -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 diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 615c7d58d8..b75e7ede89 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -806,65 +806,6 @@ export default function ApiManagerPageClient() { )} - {/* Stats Summary Cards */} - {keys.length > 0 && ( -
- -
-
- vpn_key -
-
-

{keys.length}

-

{t("totalKeys")}

-
-
-
- -
-
- lock -
-
-

- { - keys.filter((k) => Array.isArray(k.allowedModels) && k.allowedModels.length > 0) - .length - } -

-

{t("restricted")}

-
-
-
- -
-
- bar_chart -
-
-

- {Object.values(usageStats).reduce((sum, s) => sum + s.totalRequests, 0)} -

-

{t("totalRequests")}

-
-
-
- -
-
- - model_training - -
-
-

{allModels.length}

-

{t("modelsAvailable")}

-
-
-
-
- )} - {/* Filter Bar — shown when there are keys */} {keys.length > 0 && (

- {keys.length}{" "} {keys.length === 1 ? t("keyRegistered", { count: keys.length }) : t("keysRegistered", { count: keys.length })} @@ -1257,36 +1197,6 @@ export default function ApiManagerPageClient() { )} - {/* Usage Tips Card */} - -

-
- lightbulb -
-
-

{t("usageTips")}

-
    -
  • - check - {t("tipAuth")} -
  • -
  • - check - {t("tipSecure")} -
  • -
  • - check - {t("tipSeparate")} -
  • -
  • - check - {t("tipRestrict")} -
  • -
-
-
- - {/* Add Key Modal */} { - 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 (
-

Combos nomeados

+

Named combos

- Salve pipelines diferentes e atribua a combos de roteamento específicos. + Save different pipelines and assign them to specific routing combos.

@@ -197,13 +197,13 @@ function NamedCombosManager() { 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" /> 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" />
@@ -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 {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 ))} @@ -257,7 +257,7 @@ function NamedCombosManager() {
-

Pacotes de idioma

+

Language packs

{languagePacks.map((pack) => ( @@ -223,7 +219,9 @@ export default function ConnectionsHeaderToolbar({
{connections.length > 0 && ( { await handleDistributeProxies(); }} + onDistribute={async () => { + await handleDistributeProxies(); + }} disabled={batchTesting || !!retestingId} /> )} @@ -272,11 +270,7 @@ export default function ConnectionsHeaderToolbar({ ) : ( <> - {providerId === "qoder" && ( @@ -295,7 +289,7 @@ export default function ConnectionsHeaderToolbar({ icon="menu_book" onClick={() => onOpenCodexCliGuide()} > - Codex CLI Guide + {providerText(t, "codexCliGuideButton", "Codex CLI Guide")} )} {providerId === "codex" && ( @@ -305,7 +299,7 @@ export default function ConnectionsHeaderToolbar({ icon="share" onClick={() => gateConnectionFlow(openExternalLinkFlow)} > - Adicionar Externo + {providerText(t, "codexExternalLinkButton", "External Codex link")} )} {providerId === "codex" && ( @@ -349,11 +343,7 @@ export default function ConnectionsHeaderToolbar({ ) : ( connections.length === 0 && ( - ) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx index 02a4930465..09ec9ce6d3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx @@ -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 ( - +

- 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." + )}

{loading ? ( -

Gerando link…

+

+ {text("codexExternalLinkGenerating", "Generating link...")} +

) : error ? (

{error}

) : url ? ( @@ -44,19 +57,22 @@ export default function ExternalLinkModal({ icon="open_in_new" onClick={() => window.open(url, "_blank", "noopener")} > - Abrir + {tc("open")}

sync - Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha. + {text( + "codexExternalLinkWaiting", + "Waiting for browser authentication. This window refreshes automatically." + )}

) : null} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts index 125e663c03..209ea814e9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts @@ -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(null); @@ -19,7 +22,7 @@ export function useExternalLinkFlow({ const [externalLinkError, setExternalLinkError] = useState(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, diff --git a/src/app/(dashboard)/dashboard/providers/components/CodexCliGuideModal.tsx b/src/app/(dashboard)/dashboard/providers/components/CodexCliGuideModal.tsx index 76d13870b4..1d1effdb84 100644 --- a/src/app/(dashboard)/dashboard/providers/components/CodexCliGuideModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/CodexCliGuideModal.tsx @@ -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 ( - +
{loading && (
sync -

Carregando guia…

+

+ {text("codexCliGuideLoading", "Loading guide...")} +

)} {error && ( @@ -163,7 +170,9 @@ export default function CodexCliGuideModal({ isOpen, onClose }: CodexCliGuideMod error_outline -

Não foi possível carregar o guia.

+

+ {text("codexCliGuideLoadFailed", "Could not load the guide.")} +

)} diff --git a/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx b/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx index 7a7176f6aa..beed568a11 100644 --- a/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx +++ b/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx @@ -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() { />
+ + {/* Row 2 — Resilience layers (left, 2/3) + Live Feed (right, 1/3) */}
diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx b/src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx similarity index 100% rename from src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx rename to src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx index bbc0df5f3f..177a9aac43 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx @@ -145,7 +145,10 @@ function ProviderCard({ ); } -export default function ProviderCatalog({ selectedProvider, onSelectProvider }: ProviderCatalogProps) { +export default function ProviderCatalog({ + selectedProvider, + onSelectProvider, +}: ProviderCatalogProps) { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 && (
- Nenhum provider encontrado.{" "} + No provider found.{" "} - Configurar providers → + Configure providers →
)} -
+
{filtered.map((item) => ( - -

Nenhum provider de search ativo

+ +

No active search provider

- Configurar mais providers → + Configure more providers →
)} diff --git a/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx b/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx index 2b0aeb7330..f804f2940e 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx @@ -44,7 +44,7 @@ export default function SearchToolsConfigPane({ >
- Configuração + Configuration
@@ -69,7 +69,7 @@ export default function SearchToolsConfigPane({ {selectedProvider && (
- Custo:{" "} + Cost:{" "} ${selectedProvider.costPerQuery.toFixed(4)}/query @@ -97,10 +97,10 @@ export default function SearchToolsConfigPane({ } > {selectedProvider.status === "configured" - ? "Configurado" + ? "Configured" : selectedProvider.status === "rate_limited" ? "Rate limited" - : "Sem credencial"} + : "Missing credential"}
@@ -111,7 +111,7 @@ export default function SearchToolsConfigPane({ {activeTab === "search" && (
@@ -161,7 +161,7 @@ export default function SearchToolsConfigPane({ {activeTab === "compare" && (
- 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.
)} @@ -177,10 +177,7 @@ export default function SearchToolsConfigPane({ onChange={(e: React.ChangeEvent) => onConfigChange({ rerankModel: e.target.value }) } - options={[ - { value: "", label: "Nenhum" }, - ...rerankModels, - ]} + options={[{ value: "", label: "None" }, ...rerankModels]} className="w-full" />
@@ -194,7 +191,7 @@ export default function SearchToolsConfigPane({ aria-expanded={historyExpanded} > - Histórico + History
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx index 828f41adeb..a9165a7684 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx @@ -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 { 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" > - +

- Nenhum provider de search ativo — configure em Providers → + No active search provider. Configure one in Providers.

- - Configurar providers + + Configure providers
); @@ -405,8 +400,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) { {cr.provider.replace("-search", "")} {": "} - {cr.error ? "—" : computeOverlap(baseUrls, cr.urls)}{" "} - in common + {cr.error ? "—" : computeOverlap(baseUrls, cr.urls)} in common ); })} @@ -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" > - +

Select providers and enter a query to compare

diff --git a/src/app/(dashboard)/dashboard/search-tools/components/tabs/ScrapeTab.tsx b/src/app/(dashboard)/dashboard/search-tools/components/tabs/ScrapeTab.tsx index 5625663d70..c94ec90ee4 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/tabs/ScrapeTab.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/tabs/ScrapeTab.tsx @@ -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 */}
- Formato:{" "} - {configState.fetchFormat} + Format: {configState.fetchFormat} Full page:{" "} - {configState.fullPage ? "Sim" : "Não"} + + {configState.fullPage ? "Yes" : "No"} + Provider:{" "} - {configState.provider === "auto" || !configState.provider ? "auto" : configState.provider} + {configState.provider === "auto" || !configState.provider + ? "auto" + : configState.provider}
@@ -125,10 +129,7 @@ export default function ScrapeTab({ configState, onMetrics }: ScrapeTabProps) { {/* Loading spinner */} {loading && ( -
+
+
+
+
-
-
-

- {getSettingsLabel("endpointTokenSaver", "Token Saver")} -

-

- {getSettingsLabel( - "endpointTokenSaverDesc", - "Show the Token Saver panel on the Endpoint page." - )} -

-
- { - await updateSetting(SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY, checked); - }} - disabled={loading} - /> +
+
+

+ {getSettingsLabel("endpointPinInformationTitle", "Pin Information to Endpoint Page")} +

+

+ Choose which sections to pin to the top of the Endpoint page. +

+
+ +
+
+
+

+ {getSettingsLabel("endpointTokenSaver", "Token Saver")} +

+

+ {getSettingsLabel( + "endpointTokenSaverDesc", + "Show the Token Saver panel on the Endpoint page." + )} +

+ { + await updateSetting(SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY, checked); + }} + disabled={loading} + />
diff --git a/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx b/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx index d42926584f..ef35d57e96 100644 --- a/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/BackgroundDegradationTab.tsx @@ -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"}

- + ariaLabel={t("enableDegradation") || "Enable Background Degradation"} + />
{/* Stats */} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 3e9d28edac..fda5ce43bf 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -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({ ) : ( <>
-
{t("resilienceProviderCooldownEnabled")}
+
+ {t("resilienceProviderCooldownEnabled")} +
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
@@ -932,24 +933,7 @@ export default function ResilienceTab() { return (
- - -
- info -
-

- {tx("resilienceStructureTitle", "Resilience Structure")} -

-

- {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." - )} -

-
-
-
{ + 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() {
- {/* Logs Settings Section */} -
-
-
-

{t("logsSettingsTitle")}

-

- Configure detailed logging and call log pipeline settings -

-
-
-
-
- -
-
- -
-
- -
-
- -
-
-
- - {/* Cache Settings Section */} -
-
-
-

{t("cacheSettings")}

-

- Configure semantic and prompt caching behavior -

-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
@@ -888,111 +873,62 @@ export default function SystemStorageTab() {

{t("maintenance") || "Maintenance"}

-
+
- {clearCacheStatus.message && ( -
-
- - {clearCacheStatus.message} -
-
- )} -
-
- {purgeLogsStatus.message && ( -
-
- - {purgeLogsStatus.message} + +
+
+ {[clearCacheStatus, purgeLogsStatus, manualVacuumStatus] + .filter((status) => status.message) + .map((status, index) => ( +
+
+ + {status.message} +
-
- )} + ))}
diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index e828e5e6d3..963f097a3c 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -1,22 +1,30 @@ -import SettingsPageClient, { type SettingsTab } from "./SettingsPageClient"; +import { redirect } from "next/navigation"; -const LEGACY_TAB_ROUTES: Record = { - appearance: "appearance", - general: "general", - resilience: "resilience", +const LEGACY_TAB_ROUTES: Record = { + 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>; }; -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 ; + redirect(resolveSettingsRoute(tab)); } diff --git a/src/app/(dashboard)/dashboard/settings/resilience/page.tsx b/src/app/(dashboard)/dashboard/settings/resilience/page.tsx index e3e9c32290..64eae85a0f 100644 --- a/src/app/(dashboard)/dashboard/settings/resilience/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/resilience/page.tsx @@ -7,7 +7,9 @@ export default function SettingsResiliencePage() { const t = useTranslations("settings"); return (
-

{t("resilienceSettingsIntro")}

+

+ {t("resilienceSettingsIntro")} {t("resilienceStructureDesc")} +

); diff --git a/src/app/(dashboard)/dashboard/settings/routing/page.tsx b/src/app/(dashboard)/dashboard/settings/routing/page.tsx index 31dc24a2d3..49ebebd7a0 100644 --- a/src/app/(dashboard)/dashboard/settings/routing/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/routing/page.tsx @@ -12,9 +12,9 @@ export default function SettingsRoutingPage() { return (

{t("routingSettingsIntro")}

+ -
diff --git a/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx b/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx index a03474d2c2..d5c6eacc26 100644 --- a/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx @@ -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 = { 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([]); @@ -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) {

{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." )}

@@ -269,12 +265,12 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
@@ -284,9 +280,7 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) { {t("time")} - - {translateOrFallback("routeDetails", "Route")} - + {translateOrFallback("routeDetails", "Route")} {t("source")} {t("target")} {t("model")} diff --git a/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx b/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx index 11c4ec6264..320b8db951 100644 --- a/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx @@ -12,10 +12,7 @@ interface FlowNodeProps { tooltipContent?: string; } -const COLOR_MAP: Record< - FlowNodeProps["color"], - { border: string; bg: string; text: string } -> = { +const COLOR_MAP: Record = { 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 - {label && ( - {label} - )} + {label && {label}}
); } @@ -84,7 +79,7 @@ export default function TranslateFlowDiagram() { return fallback; } }, - [t], + [t] ); return ( @@ -92,18 +87,18 @@ export default function TranslateFlowDiagram() { - + @@ -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." )} />
diff --git a/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx b/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx index c612788e2e..151e2ffb77 100644 --- a/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx @@ -18,7 +18,7 @@ export default function TranslatorConceptCard() { return fallback; } }, - [t], + [t] ); return ( @@ -35,13 +35,13 @@ export default function TranslatorConceptCard() {

{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.' )}

{tr( "friendlySubtitle", - "Use sua app existente com qualquer provider — sem reescrever código.", + "Use your existing app with any provider without rewriting code." )}

@@ -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" > - {tr("conceptHowItWorksToggle", "Como funciona")} + {tr("conceptHowItWorksToggle", "How it works")} @@ -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." )} )} diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx index 3bc14deaea..309a841de4 100644 --- a/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx @@ -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 ( {t("noMatchingLogs")} ) : ( - - - +
+ + {visibleColumns.status && ( - + )} {visibleColumns.proxy && ( - + )} {visibleColumns.tls && ( - + )} {visibleColumns.type && ( - + )} {visibleColumns.level && ( - + )} {visibleColumns.provider && ( - + )} {visibleColumns.target && ( - + )} {visibleColumns.latency && ( - + )} {visibleColumns.ip && ( - + )} {visibleColumns.time && ( - + )} diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 039c2f5615..952eaaf29d 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -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{t("noMatchingLogs")} ) : ( -
- {t("colStatus")} - {t("colStatus")} - {t("colProxy")} - {t("colProxy")} - {t("colTls")} - {t("colTls")} - {t("colType")} - {t("colType")} - {t("colLevel")} - {t("colLevel")} - {t("colProvider")} - {t("colProvider")} - {t("colTarget")} - {t("colTarget")} - {t("colLatency")} - {t("colLatency")} - {t("colClientIp")} - {t("colClientIp")} - {t("colTime")} - {t("colTime")}
- - +
+ + {visibleColumns.status && ( - + )} {visibleColumns.cacheSource && ( - + )} {visibleColumns.model && ( - + )} {visibleColumns.requestedModel && ( - + )} {visibleColumns.provider && ( - + )} {visibleColumns.protocol && ( - + )} {visibleColumns.account && ( - + )} {visibleColumns.apiKey && ( - + )} {visibleColumns.combo && ( - + )} {visibleColumns.tokens && ( - + )} {visibleColumns.tps && ( - + )} {visibleColumns.duration && ( - + )} {visibleColumns.time && ( - + )} diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 663b0ec913..4d78744c33 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -242,9 +242,11 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
- Ativar camada + Enable layer - {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."}
{toggleError &&

{toggleError}

}

- 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{" "} Compression Settings @@ -269,7 +270,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { {/* ── Config form ── */}

-

Configuração

+

Configuration

- {saving ? "Salvando…" : "Salvar"} + {saving ? "Saving..." : "Save"} {saveError &&

{saveError}

}
@@ -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"}
{previewError &&

{previewError}

} {preview && (
- Tokens originais: {preview.originalTokens} + Original tokens: {preview.originalTokens} - Tokens comprimidos: {preview.compressedTokens} + Compressed tokens: {preview.compressedTokens} - Economia: {preview.savingsPct.toFixed(1)}% + Savings: {preview.savingsPct.toFixed(1)}%
)} @@ -323,17 +324,20 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { {/* ── Analytics strip ── */}
-

Últimos 7 dias

+

Last 7 days

{analytics && analytics.runs === 0 ? ( -

Sem dados ainda / No data yet

+

No data yet

) : analytics ? (
- - - + + +
) : ( -

Sem dados ainda / No data yet

+

No data yet

)}
diff --git a/src/shared/components/logTableStyles.ts b/src/shared/components/logTableStyles.ts new file mode 100644 index 0000000000..99ba7851bc --- /dev/null +++ b/src/shared/components/logTableStyles.ts @@ -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; diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index d41e4ad701..cc194d69ee 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -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 = new Set([ "costs", "logs", "health", - "settings", + "settings-general", "settings-sidebar", "docs", "changelog", @@ -988,7 +980,7 @@ const DEVELOPER_SHOWN: ReadonlySet = new Set([ "skills", "mcp", "a2a", - "settings", + "settings-general", "settings-routing", "settings-resilience", "settings-sidebar", @@ -1019,7 +1011,6 @@ const ADMIN_SHOWN: ReadonlySet = new Set([ "audit", "audit-mcp", "audit-a2a", - "settings", "settings-general", "settings-routing", "settings-resilience", diff --git a/tests/unit/dashboard-shell-tabs.test.ts b/tests/unit/dashboard-shell-tabs.test.ts index bcda5fb7fd..fb09fab916 100644 --- a/tests/unit/dashboard-shell-tabs.test.ts +++ b/tests/unit/dashboard-shell-tabs.test.ts @@ -45,19 +45,13 @@ test("endpoint page keeps APIs, MCP, and A2A as in-page tabs", () => { assert.ok(source.includes('activeEndpointTab === "a2a" ? : 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 ")); - 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", () => { diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 2b5b57566e..3051364416 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -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"), diff --git a/tests/unit/ui/system-storage-manual-vacuum.test.tsx b/tests/unit/ui/system-storage-manual-vacuum.test.tsx new file mode 100644 index 0000000000..54509e8fd2 --- /dev/null +++ b/tests/unit/ui/system-storage-manual-vacuum.test.tsx @@ -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(); + }); + // 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"); + }); +});
- {t("columns.status")} - {t("columns.status")} - {t("columns.cacheSource")} - {t("columns.cacheSource")} - {t("columns.model")} - {t("columns.model")} - {t("columns.requested")} - {t("columns.requested")} - {t("columns.provider")} - {t("columns.provider")} - {t("columns.protocol")} - {t("columns.protocol")} - {t("columns.account")} - {t("columns.account")} - {t("columns.apiKey")} - {t("columns.apiKey")} - {t("columns.combo")} - {t("columns.combo")} - {t("columns.tokens")} - {t("columns.tokens")} - {t("columns.tps")} - {t("columns.tps")} - {t("columns.duration")} - {t("columns.duration")} - {t("columns.time")} - {t("columns.time")}