feat(i18n): migrate Settings batch 4 — final 4 large tabs (80+ strings)

- ComboDefaultsTab: strategy labels, toggles, provider overrides
- PricingTab: model pricing, stats, save/reset, search
- ResilienceTab: provider profiles, rate limiting, circuit breakers, policies
- SystemStorageTab: export/import, backup/restore, database info
- Expanded settings namespace to ~290 total keys
- Completes Settings page i18n migration (17/17 files)
This commit is contained in:
diegosouzapw
2026-02-25 14:35:10 -03:00
parent 8592d02951
commit 481a630273
6 changed files with 357 additions and 200 deletions

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
export default function ComboDefaultsTab() {
const [comboDefaults, setComboDefaults] = useState<any>({
@@ -18,6 +19,8 @@ export default function ComboDefaultsTab() {
const [providerOverrides, setProviderOverrides] = useState<any>({});
const [newOverrideProvider, setNewOverrideProvider] = useState("");
const [saving, setSaving] = useState(false);
const t = useTranslations("settings");
const tc = useTranslations("common");
useEffect(() => {
fetch("/api/settings/combo-defaults")
@@ -67,17 +70,15 @@ export default function ComboDefaultsTab() {
tune
</span>
</div>
<h3 className="text-lg font-semibold">Combo Defaults</h3>
<span className="text-xs text-text-muted ml-auto">Global combo configuration</span>
<h3 className="text-lg font-semibold">{t("comboDefaultsTitle")}</h3>
<span className="text-xs text-text-muted ml-auto">{t("globalComboConfig")}</span>
</div>
<div className="flex flex-col gap-4">
{/* Default Strategy */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Default Strategy</p>
<p className="text-xs text-text-muted">
Applied to new combos without explicit strategy
</p>
<p className="font-medium text-sm">{t("defaultStrategy")}</p>
<p className="text-xs text-text-muted">{t("defaultStrategyDesc")}</p>
</div>
<div
role="tablist"
@@ -176,8 +177,8 @@ export default function ComboDefaultsTab() {
<div className="flex flex-col gap-3 pt-3 border-t border-border/50">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Health Check</p>
<p className="text-xs text-text-muted">Pre-check provider availability</p>
<p className="font-medium text-sm">{t("healthCheck")}</p>
<p className="text-xs text-text-muted">{t("healthCheckDesc")}</p>
</div>
<Toggle
checked={comboDefaults.healthCheckEnabled !== false}
@@ -191,8 +192,8 @@ export default function ComboDefaultsTab() {
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Track Metrics</p>
<p className="text-xs text-text-muted">Record per-combo request metrics</p>
<p className="font-medium text-sm">{t("trackMetrics")}</p>
<p className="text-xs text-text-muted">{t("trackMetricsDesc")}</p>
</div>
<Toggle
checked={comboDefaults.trackMetrics !== false}
@@ -205,10 +206,8 @@ export default function ComboDefaultsTab() {
{/* Provider Overrides */}
<div className="pt-3 border-t border-border/50">
<p className="font-medium text-sm mb-2">Provider Overrides</p>
<p className="text-xs text-text-muted mb-3">
Override timeout and retries per provider. Provider settings override global defaults.
</p>
<p className="font-medium text-sm mb-2">{t("providerOverrides")}</p>
<p className="text-xs text-text-muted mb-3">{t("providerOverridesDesc")}</p>
{Object.entries(providerOverrides).map(([provider, config]: [string, any]) => (
<div
@@ -230,7 +229,7 @@ export default function ComboDefaultsTab() {
className="text-xs w-16"
aria-label={`${provider} max retries`}
/>
<span className="text-[10px] text-text-muted">retries</span>
<span className="text-[10px] text-text-muted">{t("retries")}</span>
<Input
type="number"
min="5000"
@@ -249,7 +248,7 @@ export default function ComboDefaultsTab() {
className="text-xs w-24"
aria-label={`${provider} timeout ms`}
/>
<span className="text-[10px] text-text-muted">ms</span>
<span className="text-[10px] text-text-muted">{t("ms")}</span>
<button
onClick={() => removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
@@ -278,7 +277,7 @@ export default function ComboDefaultsTab() {
onClick={addProviderOverride}
disabled={!newOverrideProvider.trim()}
>
Add
{tc("add")}
</Button>
</div>
</div>
@@ -286,7 +285,7 @@ export default function ComboDefaultsTab() {
{/* Save */}
<div className="pt-3 border-t border-border/50">
<Button variant="primary" size="sm" onClick={saveComboDefaults} loading={saving}>
Save Combo Defaults
{t("saveComboDefaults")}
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const PRICING_FIELDS = ["input", "output", "cached", "reasoning", "cache_creation"];
const FIELD_LABELS = {
@@ -22,6 +23,7 @@ export default function PricingTab() {
const [expandedProviders, setExpandedProviders] = useState(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [editedProviders, setEditedProviders] = useState(new Set());
const t = useTranslations("settings");
// Load catalog + pricing
useEffect(() => {
@@ -50,9 +52,7 @@ export default function PricingTab() {
.map(([alias, info]: [string, any]) => ({
alias,
...info,
pricedModels: pricingData[alias]
? Object.keys(pricingData[alias]).length
: 0,
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
}))
.sort((a, b) => b.modelCount - a.modelCount);
return providers;
@@ -66,11 +66,7 @@ export default function PricingTab() {
(p) =>
p.alias.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q) ||
p.models.some(
(m) =>
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q)
)
p.models.some((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
);
}, [allProviders, searchQuery]);
@@ -97,23 +93,20 @@ export default function PricingTab() {
});
}, []);
const handlePricingChange = useCallback(
(provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
const handlePricingChange = useCallback((provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
},
[]
);
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
}, []);
const saveProvider = useCallback(
async (providerAlias) => {
@@ -148,36 +141,25 @@ export default function PricingTab() {
[pricingData]
);
const resetProvider = useCallback(
async (providerAlias) => {
if (
!confirm(
`Reset all pricing for ${providerAlias.toUpperCase()} to defaults?`
)
)
return;
try {
const response = await fetch(
`/api/pricing?provider=${providerAlias}`,
{ method: "DELETE" }
);
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} reset to defaults`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
} catch (error) {
setSaveStatus(`❌ Reset failed: ${error.message}`);
const resetProvider = useCallback(async (providerAlias) => {
if (!confirm(`Reset all pricing for ${providerAlias.toUpperCase()} to defaults?`)) return;
try {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, { method: "DELETE" });
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} reset to defaults`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
},
[]
);
} catch (error) {
setSaveStatus(`❌ Reset failed: ${error.message}`);
}
}, []);
const selectProviderFilter = useCallback((alias) => {
setSelectedProvider((prev) => (prev === alias ? null : alias));
@@ -194,9 +176,7 @@ export default function PricingTab() {
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-text-muted animate-pulse">
Loading pricing data...
</div>
<div className="text-text-muted animate-pulse">{t("loadingPricing")}</div>
</div>
);
}
@@ -206,30 +186,23 @@ export default function PricingTab() {
{/* Header + Stats */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h2 className="text-xl font-bold">Model Pricing</h2>
<h2 className="text-xl font-bold">{t("modelPricing")}</h2>
<p className="text-text-muted text-sm mt-1">
Configure cost rates per model All rates in{" "}
<strong>$/1M tokens</strong>
Configure cost rates per model All rates in <strong>$/1M tokens</strong>
</p>
</div>
<div className="flex gap-3 text-sm">
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">
Providers
</div>
<div className="text-text-muted text-xs font-semibold">{t("providers")}</div>
<div className="text-lg font-bold">{stats.providers}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">
Registry
</div>
<div className="text-text-muted text-xs font-semibold">{t("registry")}</div>
<div className="text-lg font-bold">{stats.totalModels}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">Priced</div>
<div className="text-lg font-bold text-success">
{stats.pricedCount as number}
</div>
<div className="text-text-muted text-xs font-semibold">{t("priced")}</div>
<div className="text-lg font-bold text-success">{stats.pricedCount as number}</div>
</div>
</div>
</div>
@@ -249,7 +222,7 @@ export default function PricingTab() {
</span>
<input
type="text"
placeholder="Search providers or models..."
placeholder={t("searchProvidersModels")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-bg-base border border-border rounded-lg focus:outline-none focus:border-primary text-sm"
@@ -261,7 +234,7 @@ export default function PricingTab() {
className="px-3 py-2 text-xs bg-primary/10 text-primary border border-primary/20 rounded-lg hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">close</span>
{selectedProvider.toUpperCase()} Show All
{selectedProvider.toUpperCase()} {t("showAll")}
</button>
)}
</div>
@@ -276,12 +249,11 @@ export default function PricingTab() {
selectedProvider === p.alias
? "bg-primary text-white shadow-sm"
: editedProviders.has(p.alias)
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
}`}
>
{p.alias.toUpperCase()}{" "}
<span className="opacity-60">({p.modelCount})</span>
{p.alias.toUpperCase()} <span className="opacity-60">({p.modelCount})</span>
</button>
))}
</div>
@@ -306,32 +278,26 @@ export default function PricingTab() {
))}
{displayProviders.length === 0 && (
<div className="text-center py-12 text-text-muted">
No providers match your search.
</div>
<div className="text-center py-12 text-text-muted">{t("noProvidersMatch")}</div>
)}
</div>
{/* Info Box */}
<Card className="p-4 mt-2">
<h3 className="text-sm font-semibold mb-2">
<span className="material-symbols-outlined text-sm align-middle mr-1">
info
</span>
How Pricing Works
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
{t("howPricingWorks")}
</h3>
<div className="text-xs text-text-muted space-y-1">
<p>
<strong>Input</strong>: tokens sent to the model {" "}
<strong>Output</strong>: tokens generated {" "}
<strong>Cached</strong>: reused input (~50% of input rate) {" "}
<strong>Input</strong>: tokens sent to the model <strong>Output</strong>: tokens
generated <strong>Cached</strong>: reused input (~50% of input rate) {" "}
<strong>Reasoning</strong>: thinking tokens (falls back to Output) {" "}
<strong>Cache Write</strong>: creating cache entries (falls back to
Input)
<strong>Cache Write</strong>: creating cache entries (falls back to Input)
</p>
<p>
Cost = (input × input_rate) + (output × output_rate) + (cached ×
cached_rate) per million tokens.
Cost = (input × input_rate) + (output × output_rate) + (cached × cached_rate) per
million tokens.
</p>
</div>
</Card>
@@ -352,20 +318,19 @@ function ProviderSection({
onReset,
saving,
}) {
const t = useTranslations("settings");
const pricedCount = Object.keys(pricingData).length;
const authBadge =
provider.authType === "oauth"
? "OAuth"
: provider.authType === "apikey"
? "API Key"
: provider.authType;
? "API Key"
: provider.authType;
return (
<div
className={`border rounded-lg overflow-hidden transition-colors ${
isEdited
? "border-yellow-500/40 bg-yellow-500/5"
: "border-border"
isEdited ? "border-yellow-500/40 bg-yellow-500/5" : "border-border"
}`}
>
{/* Header (click to expand) */}
@@ -385,9 +350,7 @@ function ProviderSection({
<span className="font-semibold text-sm">
{provider.id.charAt(0).toUpperCase() + provider.id.slice(1)}
</span>
<span className="text-text-muted text-xs ml-2">
({provider.alias.toUpperCase()})
</span>
<span className="text-text-muted text-xs ml-2">({provider.alias.toUpperCase()})</span>
</div>
<span className="px-1.5 py-0.5 bg-bg-subtle text-text-muted text-[10px] rounded uppercase font-semibold">
{authBadge}
@@ -397,11 +360,7 @@ function ProviderSection({
</span>
</div>
<div className="flex items-center gap-3">
{isEdited && (
<span className="text-yellow-500 text-xs font-medium">
unsaved
</span>
)}
{isEdited && <span className="text-yellow-500 text-xs font-medium">{t("unsaved")}</span>}
<span className="text-text-muted text-xs">
{pricedCount}/{provider.modelCount} priced
</span>
@@ -426,8 +385,7 @@ function ProviderSection({
{/* Actions bar */}
<div className="flex items-center justify-between px-4 py-2 bg-bg-subtle/50">
<span className="text-xs text-text-muted">
{provider.modelCount} models {" "}
{pricedCount} with pricing configured
{provider.modelCount} models {pricedCount} with pricing configured
</span>
<div className="flex items-center gap-2">
<button
@@ -437,7 +395,7 @@ function ProviderSection({
}}
className="px-2.5 py-1 text-[11px] text-red-400 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
>
Reset Defaults
{t("resetDefaults")}
</button>
<button
onClick={(e) => {
@@ -447,7 +405,7 @@ function ProviderSection({
disabled={saving || !isEdited}
className="px-2.5 py-1 text-[11px] bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-40"
>
{saving ? "Saving..." : "Save Provider"}
{saving ? t("saving") : t("saveProvider")}
</button>
</div>
</div>
@@ -457,12 +415,9 @@ function ProviderSection({
<table className="w-full text-sm">
<thead className="text-[11px] text-text-muted uppercase bg-bg-subtle/30">
<tr>
<th className="px-4 py-2 text-left font-semibold">Model</th>
<th className="px-4 py-2 text-left font-semibold">{t("model")}</th>
{PRICING_FIELDS.map((field) => (
<th
key={field}
className="px-2 py-2 text-right font-semibold w-24"
>
<th key={field} className="px-2 py-2 text-right font-semibold w-24">
{FIELD_LABELS[field]}
</th>
))}
@@ -474,9 +429,7 @@ function ProviderSection({
key={model.id}
model={model}
pricing={pricingData[model.id]}
onPricingChange={(field, value) =>
onPricingChange(model.id, field, value)
}
onPricingChange={(field, value) => onPricingChange(model.id, field, value)}
/>
))}
</tbody>
@@ -498,9 +451,7 @@ function ModelRow({ model, pricing, onPricingChange }) {
<td className="px-4 py-1.5">
<div className="flex items-center gap-2">
<span
className={`w-1.5 h-1.5 rounded-full ${
hasPricing ? "bg-success" : "bg-text-muted/30"
}`}
className={`w-1.5 h-1.5 rounded-full ${hasPricing ? "bg-success" : "bg-text-muted/30"}`}
/>
<span className="font-medium text-xs">{model.name}</span>
{model.custom && (

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
@@ -46,6 +47,8 @@ function formatMs(ms) {
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(profiles);
const t = useTranslations("settings");
const tc = useTranslations("common");
useEffect(() => {
setDraft(profiles);
@@ -72,12 +75,12 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
tune
</span>
<h2 className="text-lg font-bold">Provider Profiles</h2>
<h2 className="text-lg font-bold">{t("providerProfiles")}</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
{tc("cancel")}
</Button>
<Button
size="sm"
@@ -86,20 +89,17 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
onClick={handleSave}
disabled={saving}
>
Save
{tc("save")}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
{tc("edit")}
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
Separate resilience settings for OAuth (session-based) and API Key (metered) providers.
OAuth providers have stricter thresholds due to lower rate limits.
</p>
<p className="text-sm text-text-muted mb-4">{t("providerProfilesDesc")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{["oauth", "apikey"].map((type) => (
@@ -108,7 +108,7 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
<span className="material-symbols-outlined text-base" aria-hidden="true">
{type === "oauth" ? "lock" : "key"}
</span>
{type === "oauth" ? "OAuth Providers" : "API Key Providers"}
{type === "oauth" ? t("oauthProviders") : t("apiKeyProviders")}
</h3>
<div className="space-y-2">
{fields.map(({ key, label, suffix }) => (
@@ -148,6 +148,8 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(defaults || {});
const t = useTranslations("settings");
const tc = useTranslations("common");
// Sync draft when defaults change from parent (standard prop-to-state sync)
/* eslint-disable react-hooks/set-state-in-effect */
@@ -169,12 +171,12 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
<h2 className="text-lg font-bold">{t("rateLimiting")}</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
{tc("cancel")}
</Button>
<Button
size="sm"
@@ -183,24 +185,21 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
onClick={handleSave}
disabled={saving}
>
Save
{tc("save")}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
{tc("edit")}
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
API Key providers are automatically rate-limited with safe defaults. Limits are learned
from response headers and adapt over time.
</p>
<p className="text-sm text-text-muted mb-4">{t("rateLimitingDesc")}</p>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-4 mb-4">
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 text-text-muted">
Default Safety Net
{t("defaultSafetyNet")}
</h3>
<div className="grid grid-cols-3 gap-4">
{[
@@ -233,7 +232,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
{rateLimitStatus && rateLimitStatus.length > 0 ? (
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-text-muted">
Active Limiters
{t("activeLimiters")}
</h3>
{rateLimitStatus.map((rl, i) => (
<div
@@ -250,7 +249,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
))}
</div>
) : (
<p className="text-xs text-text-muted">No active rate limiters yet.</p>
<p className="text-xs text-text-muted">{t("noActiveLimiters")}</p>
)}
</div>
</Card>
@@ -261,6 +260,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
const t = useTranslations("settings");
return (
<Card className="p-0 overflow-hidden">
@@ -270,7 +270,7 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
<h2 className="text-lg font-bold">{t("circuitBreakers")}</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
@@ -286,17 +286,14 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
onClick={onReset}
disabled={loading}
>
Reset All
{t("resetAll")}
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
<p className="text-sm text-text-muted">{t("noCircuitBreakers")}</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
@@ -343,6 +340,7 @@ function PoliciesCard() {
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const t = useTranslations("settings");
const fetchPolicies = useCallback(async () => {
try {
@@ -394,7 +392,7 @@ function PoliciesCard() {
<Card className="p-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">policy</span>
Loading policies...
{t("loadingPolicies")}
</div>
</Card>
);
@@ -408,7 +406,7 @@ function PoliciesCard() {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
policy
</span>
<h2 className="text-lg font-bold">Policies & Locked Identifiers</h2>
<h2 className="text-lg font-bold">{t("policiesLocked")}</h2>
</div>
{hasIssues && (
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
@@ -423,9 +421,7 @@ function PoliciesCard() {
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
<p className="text-sm text-text-muted">{t("allOperational")}</p>
</div>
</div>
) : (
@@ -433,7 +429,7 @@ function PoliciesCard() {
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("circuitBreakers")}</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
@@ -479,7 +475,7 @@ function PoliciesCard() {
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("lockedIdentifiers")}</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
@@ -506,7 +502,7 @@ function PoliciesCard() {
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
</Button>
</div>
);
@@ -527,6 +523,7 @@ export default function ResilienceTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const t = useTranslations("settings");
const loadData = useCallback(async () => {
try {
@@ -601,7 +598,7 @@ export default function ResilienceTab() {
return (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">hourglass_empty</span>
Loading resilience status...
{t("loadingResilience")}
</div>
);
}
@@ -614,7 +611,7 @@ export default function ResilienceTab() {
<span className="text-sm">{error}</span>
</div>
<Button size="sm" variant="secondary" icon="refresh" onClick={loadData} className="mt-3">
Retry
{t("retry")}
</Button>
</Card>
);

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Badge } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function SystemStorageTab() {
const [backups, setBackups] = useState([]);
@@ -18,6 +19,8 @@ export default function SystemStorageTab() {
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const t = useTranslations("settings");
const tc = useTranslations("common");
const [storageHealth, setStorageHealth] = useState({
driver: "sqlite",
dbPath: "~/.omniroute/storage.sqlite",
@@ -149,7 +152,7 @@ export default function SystemStorageTab() {
if (!file.name.endsWith(".sqlite")) {
setImportStatus({
type: "error",
message: "Invalid file type. Only .sqlite files are accepted.",
message: t("invalidFileType"),
});
return;
}
@@ -224,8 +227,8 @@ export default function SystemStorageTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">System & Storage</h3>
<p className="text-xs text-text-muted">All data stored locally on your machine</p>
<h3 className="text-lg font-semibold">{t("systemStorage")}</h3>
<p className="text-xs text-text-muted">{t("allDataLocal")}</p>
</div>
<Badge variant="success" size="sm">
{storageHealth.driver || "json"}
@@ -235,13 +238,17 @@ export default function SystemStorageTab() {
{/* Storage info grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-bg border border-border">
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">Database Path</p>
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">
{t("databasePath")}
</p>
<p className="text-sm font-mono text-text-main break-all">
{storageHealth.dbPath || "~/.omniroute/storage.sqlite"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg border border-border">
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">Database Size</p>
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">
{t("databaseSize")}
</p>
<p className="text-sm font-mono text-text-main">{formatBytes(storageHealth.sizeBytes)}</p>
</div>
</div>
@@ -252,7 +259,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
download
</span>
Export Database
{t("exportDatabase")}
</Button>
<Button
variant="outline"
@@ -288,13 +295,13 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
folder_zip
</span>
Export All (.tar.gz)
{t("exportAll")}
</Button>
<Button variant="outline" size="sm" onClick={handleImportClick} loading={importLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
upload
</span>
Import Database
{t("importDatabase")}
</Button>
<input
ref={fileInputRef}
@@ -316,7 +323,7 @@ export default function SystemStorageTab() {
warning
</span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-500 mb-1">Confirm Database Import</p>
<p className="text-sm font-medium text-amber-500 mb-1">{t("confirmDbImport")}</p>
<p className="text-xs text-text-muted mb-2">
This will replace <strong>all current data</strong> with the content from{" "}
<span className="font-mono">{pendingImportFile.name}</span>. A backup will be
@@ -329,10 +336,10 @@ export default function SystemStorageTab() {
onClick={handleImportConfirm}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes, Import
{t("yesImport")}
</Button>
<Button variant="outline" size="sm" onClick={handleImportCancel}>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
@@ -364,11 +371,11 @@ export default function SystemStorageTab() {
schedule
</span>
<div>
<p className="text-sm font-medium">Last Backup</p>
<p className="text-sm font-medium">{t("lastBackup")}</p>
<p className="text-xs text-text-muted">
{storageHealth.lastBackupAt
? `${new Date(storageHealth.lastBackupAt).toLocaleString("pt-BR")} (${formatRelativeTime(storageHealth.lastBackupAt)})`
: "No backup yet"}
: t("noBackupYet")}
</p>
</div>
</div>
@@ -381,7 +388,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
backup
</span>
Backup Now
{t("backupNow")}
</Button>
</div>
@@ -419,7 +426,7 @@ export default function SystemStorageTab() {
>
restore
</span>
<p className="font-medium">Backup & Restore</p>
<p className="font-medium">{t("backupRestore")}</p>
</div>
<Button
variant="outline"
@@ -429,13 +436,10 @@ export default function SystemStorageTab() {
if (!backupsExpanded && backups.length === 0) loadBackups();
}}
>
{backupsExpanded ? "Hide" : "View Backups"}
{backupsExpanded ? t("hide") : t("viewBackups")}
</Button>
</div>
<p className="text-xs text-text-muted mb-3">
Database snapshots are created automatically before restore and every 15 minutes when data
changes. Retention: 24 hourly + 30 daily backups with smart rotation.
</p>
<p className="text-xs text-text-muted mb-3">{t("backupRetentionDesc")}</p>
{restoreStatus.message && (
<div
@@ -465,7 +469,7 @@ export default function SystemStorageTab() {
>
progress_activity
</span>
Loading backups...
{t("loadingBackups")}
</div>
) : backups.length === 0 ? (
<div className="text-center py-6 text-text-muted text-sm">
@@ -475,7 +479,7 @@ export default function SystemStorageTab() {
>
folder_off
</span>
No backups available yet. Backups will be created automatically when data changes.
{t("noBackupsYet")}
</div>
) : (
<>
@@ -490,7 +494,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh
</span>
Refresh
{t("refresh")}
</button>
</div>
{backups.map((backup) => (
@@ -531,7 +535,7 @@ export default function SystemStorageTab() {
<div className="flex items-center gap-2 ml-3">
{confirmRestoreId === backup.id ? (
<>
<span className="text-xs text-amber-500 font-medium">Confirm?</span>
<span className="text-xs text-amber-500 font-medium">{t("confirm")}</span>
<Button
variant="primary"
size="sm"
@@ -539,14 +543,14 @@ export default function SystemStorageTab() {
loading={restoringId === backup.id}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes
{t("yes")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmRestoreId(null)}
>
No
{t("no")}
</Button>
</>
) : (
@@ -561,7 +565,7 @@ export default function SystemStorageTab() {
>
restore
</span>
Restore
{t("restore")}
</Button>
)}
</div>

View File

@@ -749,7 +749,110 @@
"failedCreateChain": "Failed to create chain",
"failedDeleteChain": "Failed to delete chain",
"fillModelAndProviders": "Please fill model name and providers",
"addAtLeastOneProvider": "Add at least one provider"
"addAtLeastOneProvider": "Add at least one provider",
"comboDefaultsTitle": "Combo Defaults",
"globalComboConfig": "Global combo configuration",
"defaultStrategy": "Default Strategy",
"defaultStrategyDesc": "Applied to new combos without explicit strategy",
"priority": "Priority",
"weighted": "Weighted",
"healthCheck": "Health Check",
"healthCheckDesc": "Pre-check provider availability",
"trackMetrics": "Track Metrics",
"trackMetricsDesc": "Record per-combo request metrics",
"providerOverrides": "Provider Overrides",
"providerOverridesDesc": "Override timeout and retries per provider. Provider settings override global defaults.",
"retries": "retries",
"ms": "ms",
"saveComboDefaults": "Save Combo Defaults",
"maxNestingDepth": "Max Nesting Depth",
"concurrencyPerModel": "Concurrency / Model",
"queueTimeout": "Queue Timeout (ms)",
"providerProfiles": "Provider Profiles",
"providerProfilesDesc": "Separate resilience settings for OAuth (session-based) and API Key (metered) providers. OAuth providers have stricter thresholds due to lower rate limits.",
"oauthProviders": "OAuth Providers",
"apiKeyProviders": "API Key Providers",
"transientCooldown": "Transient Cooldown",
"rateLimitCooldown": "Rate Limit Cooldown",
"maxBackoffLevel": "Max Backoff Level",
"cbThreshold": "CB Threshold",
"cbResetTime": "CB Reset Time",
"rateLimiting": "Rate Limiting",
"rateLimitingDesc": "API Key providers are automatically rate-limited with safe defaults. Limits are learned from response headers and adapt over time.",
"defaultSafetyNet": "Default Safety Net",
"rpm": "RPM",
"minGap": "Min Gap",
"maxConcurrent": "Max Concurrent",
"activeLimiters": "Active Limiters",
"noActiveLimiters": "No active rate limiters yet.",
"reservoir": "Reservoir",
"running": "Running",
"queued": "Queued",
"circuitBreakers": "Circuit Breakers",
"tripped": "{count} tripped",
"healthy": "{count} healthy",
"resetAll": "Reset All",
"noCircuitBreakers": "No circuit breakers active yet. They are created automatically when requests flow through the combo pipeline.",
"failures": "{count} failure(s)",
"policiesLocked": "Policies & Locked Identifiers",
"allOperational": "All systems operational — no lockouts or tripped breakers",
"loadingPolicies": "Loading policies...",
"lockedIdentifiers": "Locked Identifiers",
"forceUnlock": "Force Unlock",
"unlocking": "Unlocking...",
"failedUnlock": "Failed to unlock",
"loadingResilience": "Loading resilience status...",
"retry": "Retry",
"systemStorage": "System & Storage",
"allDataLocal": "All data stored locally on your machine",
"databasePath": "Database Path",
"exportDatabase": "Export Database",
"exportAll": "Export All (.tar.gz)",
"importDatabase": "Import Database",
"confirmDbImport": "Confirm Database Import",
"confirmDbImportDesc": "This will replace all current data with the content from {file}. A backup will be created automatically before the import.",
"yesImport": "Yes, Import",
"lastBackup": "Last Backup",
"noBackupYet": "No backup yet",
"backupNow": "Backup Now",
"backupRestore": "Backup & Restore",
"viewBackups": "View Backups",
"hide": "Hide",
"backupRetentionDesc": "Database snapshots are created automatically before restore and every 15 minutes when data changes. Retention: 24 hourly + 30 daily backups with smart rotation.",
"loadingBackups": "Loading backups...",
"noBackupsYet": "No backups available yet. Backups will be created automatically when data changes.",
"backupsAvailable": "{count} backup(s) available",
"refresh": "Refresh",
"confirm": "Confirm?",
"yes": "Yes",
"no": "No",
"restore": "Restore",
"invalidFileType": "Invalid file type. Only .sqlite files are accepted.",
"noChangesSinceBackup": "No changes since last backup",
"backupFailed": "Backup failed",
"restoreFailed": "Restore failed",
"importFailed": "Import failed",
"errorDuringRestore": "An error occurred during restore",
"errorDuringImport": "An error occurred during import",
"modelPricing": "Model Pricing",
"modelPricingDesc": "Configure cost rates per model • All rates in $/1M tokens",
"providers": "Providers",
"registry": "Registry",
"priced": "Priced",
"searchProvidersModels": "Search providers or models...",
"showAll": "Show All",
"noProvidersMatch": "No providers match your search.",
"howPricingWorks": "How Pricing Works",
"cacheWrite": "Cache Write",
"unsaved": "unsaved",
"resetDefaults": "Reset Defaults",
"saveProvider": "Save Provider",
"saving": "Saving...",
"model": "Model",
"models": "models",
"withPricing": "with pricing configured",
"loadingPricing": "Loading pricing data...",
"databaseSize": "Database Size"
},
"translator": {
"title": "Translator",

View File

@@ -749,7 +749,110 @@
"failedCreateChain": "Falha ao criar cadeia",
"failedDeleteChain": "Falha ao excluir cadeia",
"fillModelAndProviders": "Preencha o nome do modelo e os provedores",
"addAtLeastOneProvider": "Adicione pelo menos um provedor"
"addAtLeastOneProvider": "Adicione pelo menos um provedor",
"comboDefaultsTitle": "Padrões de Combo",
"globalComboConfig": "Configuração global de combos",
"defaultStrategy": "Estratégia Padrão",
"defaultStrategyDesc": "Aplicada a novos combos sem estratégia explícita",
"priority": "Prioridade",
"weighted": "Ponderado",
"healthCheck": "Verificação de Saúde",
"healthCheckDesc": "Verificar disponibilidade do provedor antes",
"trackMetrics": "Rastrear Métricas",
"trackMetricsDesc": "Registrar métricas de requisição por combo",
"providerOverrides": "Sobrescritas por Provedor",
"providerOverridesDesc": "Substituir timeout e tentativas por provedor. Configurações do provedor substituem os padrões globais.",
"retries": "tentativas",
"ms": "ms",
"saveComboDefaults": "Salvar Padrões de Combo",
"maxNestingDepth": "Profundidade Máx. de Aninhamento",
"concurrencyPerModel": "Concorrência / Modelo",
"queueTimeout": "Timeout da Fila (ms)",
"providerProfiles": "Perfis de Provedor",
"providerProfilesDesc": "Configurações de resiliência separadas para provedores OAuth (baseados em sessão) e API Key (medidos). Provedores OAuth têm limites mais rigorosos devido a taxas mais baixas.",
"oauthProviders": "Provedores OAuth",
"apiKeyProviders": "Provedores API Key",
"transientCooldown": "Cooldown Transitório",
"rateLimitCooldown": "Cooldown de Rate Limit",
"maxBackoffLevel": "Nível Máx. de Backoff",
"cbThreshold": "Limiar do CB",
"cbResetTime": "Tempo de Reset do CB",
"rateLimiting": "Limitação de Taxa",
"rateLimitingDesc": "Provedores API Key são automaticamente limitados com padrões seguros. Limites são aprendidos dos cabeçalhos de resposta e se adaptam ao longo do tempo.",
"defaultSafetyNet": "Rede de Segurança Padrão",
"rpm": "RPM",
"minGap": "Intervalo Mín.",
"maxConcurrent": "Máx. Concorrentes",
"activeLimiters": "Limitadores Ativos",
"noActiveLimiters": "Nenhum limitador de taxa ativo ainda.",
"reservoir": "Reservatório",
"running": "Em Execução",
"queued": "Na Fila",
"circuitBreakers": "Disjuntores",
"tripped": "{count} aberto(s)",
"healthy": "{count} saudável(is)",
"resetAll": "Resetar Todos",
"noCircuitBreakers": "Nenhum disjuntor ativo ainda. Eles são criados automaticamente quando requisições passam pelo pipeline de combos.",
"failures": "{count} falha(s)",
"policiesLocked": "Políticas e Identificadores Bloqueados",
"allOperational": "Todos os sistemas operacionais — sem bloqueios ou disjuntores ativados",
"loadingPolicies": "Carregando políticas...",
"lockedIdentifiers": "Identificadores Bloqueados",
"forceUnlock": "Forçar Desbloqueio",
"unlocking": "Desbloqueando...",
"failedUnlock": "Falha ao desbloquear",
"loadingResilience": "Carregando status de resiliência...",
"retry": "Tentar Novamente",
"systemStorage": "Sistema e Armazenamento",
"allDataLocal": "Todos os dados armazenados localmente na sua máquina",
"databasePath": "Caminho do Banco de Dados",
"exportDatabase": "Exportar Banco de Dados",
"exportAll": "Exportar Tudo (.tar.gz)",
"importDatabase": "Importar Banco de Dados",
"confirmDbImport": "Confirmar Importação do Banco",
"confirmDbImportDesc": "Isso substituirá todos os dados atuais pelo conteúdo de {file}. Um backup será criado automaticamente antes da importação.",
"yesImport": "Sim, Importar",
"lastBackup": "Último Backup",
"noBackupYet": "Nenhum backup ainda",
"backupNow": "Backup Agora",
"backupRestore": "Backup e Restauração",
"viewBackups": "Ver Backups",
"hide": "Ocultar",
"backupRetentionDesc": "Snapshots do banco são criados automaticamente antes da restauração e a cada 15 minutos quando há alterações. Retenção: 24 horários + 30 diários com rotação inteligente.",
"loadingBackups": "Carregando backups...",
"noBackupsYet": "Nenhum backup disponível ainda. Backups serão criados automaticamente quando houver alterações.",
"backupsAvailable": "{count} backup(s) disponível(is)",
"refresh": "Atualizar",
"confirm": "Confirmar?",
"yes": "Sim",
"no": "Não",
"restore": "Restaurar",
"invalidFileType": "Tipo de arquivo inválido. Apenas arquivos .sqlite são aceitos.",
"noChangesSinceBackup": "Sem alterações desde o último backup",
"backupFailed": "Falha no backup",
"restoreFailed": "Falha na restauração",
"importFailed": "Falha na importação",
"errorDuringRestore": "Ocorreu um erro durante a restauração",
"errorDuringImport": "Ocorreu um erro durante a importação",
"modelPricing": "Preços de Modelos",
"modelPricingDesc": "Configure taxas de custo por modelo • Todas as taxas em $/1M tokens",
"providers": "Provedores",
"registry": "Registro",
"priced": "Com Preço",
"searchProvidersModels": "Buscar provedores ou modelos...",
"showAll": "Mostrar Todos",
"noProvidersMatch": "Nenhum provedor corresponde à sua busca.",
"howPricingWorks": "Como os Preços Funcionam",
"cacheWrite": "Escrita em Cache",
"unsaved": "não salvo",
"resetDefaults": "Restaurar Padrões",
"saveProvider": "Salvar Provedor",
"saving": "Salvando...",
"model": "Modelo",
"models": "modelos",
"withPricing": "com preço configurado",
"loadingPricing": "Carregando dados de preços...",
"databaseSize": "Tamanho do Banco de Dados"
},
"translator": {
"title": "Tradutor",