mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
feat(resilience): expose model cooldown list with manual re-enable
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
type CooldownItem = {
|
||||
provider: string;
|
||||
model: string;
|
||||
reason: string;
|
||||
remainingMs: number;
|
||||
unavailableSince: string;
|
||||
};
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
const totalSec = Math.max(0, Math.ceil(ms / 1000));
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
export default function ModelCooldownsCard() {
|
||||
const notify = useNotificationStore();
|
||||
const [items, setItems] = useState<CooldownItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/resilience/model-cooldowns", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
setItems(Array.isArray(json.items) ? json.items : []);
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to load cooldowns");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const clearOne = useCallback(
|
||||
async (provider: string, model: string) => {
|
||||
const key = `${provider}::${model}`;
|
||||
setBusyKey(key);
|
||||
try {
|
||||
const res = await fetch("/api/resilience/model-cooldowns", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, model }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
notify.success(`Modelo reativado: ${provider}/${model}`);
|
||||
await load();
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to clear cooldown");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
},
|
||||
[load, notify]
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
setBusyKey("ALL");
|
||||
try {
|
||||
const res = await fetch("/api/resilience/model-cooldowns", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ all: true }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
notify.success("Todos os modelos em cooldown foram reativados.");
|
||||
await load();
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to clear cooldowns");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [load, notify]);
|
||||
|
||||
const hasItems = items.length > 0;
|
||||
const sorted = useMemo(() => [...items].sort((a, b) => b.remainingMs - a.remainingMs), [items]);
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main">Modelos em cooldown</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Lista de modelos temporariamente isolados por falha. Quando o cooldown expira, eles
|
||||
voltam automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void load()} disabled={loading}>
|
||||
Atualizar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => void clearAll()}
|
||||
disabled={!hasItems || busyKey === "ALL"}
|
||||
>
|
||||
Reativar todos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Carregando...</p>
|
||||
) : !hasItems ? (
|
||||
<p className="text-sm text-text-muted">Nenhum modelo em cooldown no momento.</p>
|
||||
) : (
|
||||
sorted.map((item) => {
|
||||
const rowKey = `${item.provider}::${item.model}`;
|
||||
return (
|
||||
<div
|
||||
key={rowKey}
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-main truncate">
|
||||
{item.provider}/{item.model}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
motivo: {item.reason} • restante: {formatRemaining(item.remainingMs)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void clearOne(item.provider, item.model)}
|
||||
disabled={busyKey === rowKey}
|
||||
>
|
||||
Reativar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import AutoDisableCard from "./AutoDisableCard";
|
||||
import ModelCooldownsCard from "./ModelCooldownsCard";
|
||||
|
||||
type RequestQueueSettings = {
|
||||
autoEnableApiKeyProviders: boolean;
|
||||
@@ -60,13 +62,13 @@ function SectionDescription({
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Scope:</span> {scope}
|
||||
<span className="font-semibold text-text-main">Escopo:</span> {scope}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
|
||||
<span className="font-semibold text-text-main">Gatilho:</span> {trigger}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Effect:</span> {effect}
|
||||
<span className="font-semibold text-text-main">Efeito:</span> {effect}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -211,12 +213,12 @@ function RequestQueueCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">speed</span>
|
||||
<h2 className="text-lg font-bold">Request Queue & Pacing</h2>
|
||||
<h2 className="text-lg font-bold">Fila de Requisições e Ritmo</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Per request bucket"
|
||||
trigger="Before a request is sent upstream"
|
||||
effect="Queues requests, limits concurrency, and spaces requests out"
|
||||
scope="Por fila de requisição"
|
||||
trigger="Antes de enviar para o upstream"
|
||||
effect="Enfileira requisições, limita concorrência e espaça as chamadas"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -235,28 +237,29 @@ function RequestQueueCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
This layer only controls queueing and pacing. It does not write cooldowns or open breakers.
|
||||
Esta camada controla apenas enfileiramento e ritmo. Ela não grava cooldown nem abre circuit
|
||||
breaker.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Auto-enable for API key providers"
|
||||
description="Enable queue protection by default for active API key connections."
|
||||
label="Autoativar para provedores com API key"
|
||||
description="Ativa proteção de fila por padrão para conexões de API key ativas."
|
||||
checked={draft.autoEnableApiKeyProviders}
|
||||
onChange={(autoEnableApiKeyProviders) =>
|
||||
setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders }))
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Requests per minute"
|
||||
label="Requisições por minuto"
|
||||
value={draft.requestsPerMinute}
|
||||
min={1}
|
||||
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Min time between requests"
|
||||
label="Tempo mínimo entre requisições"
|
||||
value={draft.minTimeBetweenRequestsMs}
|
||||
suffix="ms"
|
||||
onChange={(minTimeBetweenRequestsMs) =>
|
||||
@@ -264,7 +267,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Concurrent requests"
|
||||
label="Requisições concorrentes"
|
||||
value={draft.concurrentRequests}
|
||||
min={1}
|
||||
onChange={(concurrentRequests) =>
|
||||
@@ -272,7 +275,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Max queue wait"
|
||||
label="Tempo máximo de espera em fila"
|
||||
value={draft.maxWaitMs}
|
||||
min={1}
|
||||
suffix="ms"
|
||||
@@ -282,31 +285,31 @@ function RequestQueueCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Auto-enable for API key providers</div>
|
||||
<div className="text-xs text-text-muted">Autoativar para provedores com API key</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
|
||||
{value.autoEnableApiKeyProviders ? "Ativado" : "Desativado"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Requests per minute</div>
|
||||
<div className="text-xs text-text-muted">Requisições por minuto</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.requestsPerMinute}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Min time between requests</div>
|
||||
<div className="text-xs text-text-muted">Tempo mínimo entre requisições</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.minTimeBetweenRequestsMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Concurrent requests</div>
|
||||
<div className="text-xs text-text-muted">Requisições concorrentes</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.concurrentRequests}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Max queue wait</div>
|
||||
<div className="text-xs text-text-muted">Tempo máximo de espera em fila</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.maxWaitMs)}
|
||||
</div>
|
||||
@@ -341,7 +344,7 @@ function ConnectionCooldownCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Base cooldown"
|
||||
label="Cooldown base"
|
||||
value={current.baseCooldownMs}
|
||||
min={0}
|
||||
suffix="ms"
|
||||
@@ -350,8 +353,8 @@ function ConnectionCooldownCard({
|
||||
}
|
||||
/>
|
||||
<BooleanField
|
||||
label="Use upstream retry hints"
|
||||
description="Use upstream retry-after/reset values when they are present."
|
||||
label="Usar dicas de retry do upstream"
|
||||
description="Usa valores de retry-after/reset do upstream quando disponíveis."
|
||||
checked={current.useUpstreamRetryHints}
|
||||
onChange={(useUpstreamRetryHints) =>
|
||||
setDraft((prev) => ({
|
||||
@@ -361,7 +364,7 @@ function ConnectionCooldownCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Max backoff steps"
|
||||
label="Máximo de passos de backoff"
|
||||
value={current.maxBackoffSteps}
|
||||
min={0}
|
||||
onChange={(maxBackoffSteps) =>
|
||||
@@ -372,17 +375,17 @@ function ConnectionCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Base cooldown</span>
|
||||
<span className="text-text-muted">Cooldown base</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Use upstream retry hints</span>
|
||||
<span className="text-text-muted">Usar dicas de retry do upstream</span>
|
||||
<span className="font-mono text-text-main">
|
||||
{current.useUpstreamRetryHints ? "Yes" : "No"}
|
||||
{current.useUpstreamRetryHints ? "Sim" : "Não"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Max backoff steps</span>
|
||||
<span className="text-text-muted">Máximo de passos de backoff</span>
|
||||
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -397,12 +400,12 @@ function ConnectionCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">timer_off</span>
|
||||
<h2 className="text-lg font-bold">Connection Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">Cooldown de Conexão</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Single connection"
|
||||
trigger="A connection returns a retryable upstream failure"
|
||||
effect="Temporarily skips that connection and increases backoff on repeated failures"
|
||||
scope="Conexão individual"
|
||||
trigger="Quando uma conexão retorna falha transitória no upstream"
|
||||
effect="Pula temporariamente essa conexão e aumenta backoff em falhas repetidas"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -421,13 +424,13 @@ function ConnectionCooldownCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
Base cooldown covers retryable connection failures. When upstream retry hints are enabled,
|
||||
explicit provider wait windows override the local base cooldown.
|
||||
O cooldown base cobre falhas transitórias de conexão. Quando as dicas de retry do upstream
|
||||
estão ativas, a janela explícita do provedor sobrescreve o cooldown local.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{renderProfile("oauth", "OAuth Providers", "lock")}
|
||||
{renderProfile("apikey", "API Key Providers", "key")}
|
||||
{renderProfile("oauth", "Provedores OAuth", "lock")}
|
||||
{renderProfile("apikey", "Provedores API Key", "key")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -456,7 +459,7 @@ function ProviderBreakerCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Failure threshold"
|
||||
label="Limite de falhas"
|
||||
value={current.failureThreshold}
|
||||
min={1}
|
||||
onChange={(failureThreshold) =>
|
||||
@@ -464,7 +467,7 @@ function ProviderBreakerCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Reset timeout"
|
||||
label="Tempo para reset"
|
||||
value={current.resetTimeoutMs}
|
||||
min={1000}
|
||||
suffix="ms"
|
||||
@@ -476,11 +479,11 @@ function ProviderBreakerCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Failure threshold</span>
|
||||
<span className="text-text-muted">Limite de falhas</span>
|
||||
<span className="font-mono text-text-main">{current.failureThreshold}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Reset timeout</span>
|
||||
<span className="text-text-muted">Tempo para reset</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -497,12 +500,12 @@ function ProviderBreakerCard({
|
||||
<span className="material-symbols-outlined text-xl text-primary">
|
||||
electrical_services
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Provider Circuit Breaker</h2>
|
||||
<h2 className="text-lg font-bold">Circuit Breaker por Provedor</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Whole provider"
|
||||
trigger="Provider-level final transport/server failures after connection fallback is exhausted"
|
||||
effect="Temporarily blocks that provider until the reset timeout elapses"
|
||||
scope="Provedor inteiro"
|
||||
trigger="Falhas finais de transporte/servidor após esgotar fallback de conexão"
|
||||
effect="Bloqueia temporariamente esse provedor até o tempo de reset expirar"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -521,13 +524,13 @@ function ProviderBreakerCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
Breaker runtime state is shown only on the Health page. Connection-scoped 429 rate limits
|
||||
stay in Connection Cooldown and do not trip the provider breaker.
|
||||
O estado em tempo real do breaker aparece apenas na página Saúde. Rate limits 429 no escopo
|
||||
de conexão ficam no Cooldown de Conexão e não disparam o breaker de provedor.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{renderProfile("oauth", "OAuth Providers", "lock")}
|
||||
{renderProfile("apikey", "API Key Providers", "key")}
|
||||
{renderProfile("oauth", "Provedores OAuth", "lock")}
|
||||
{renderProfile("apikey", "Provedores API Key", "key")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -555,12 +558,12 @@ function WaitForCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
|
||||
<h2 className="text-lg font-bold">Wait For Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">Aguardar Cooldown</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Current client request"
|
||||
trigger="All candidate connections are already cooling down"
|
||||
effect="Waits on the server side and retries when the earliest cooldown expires"
|
||||
scope="Requisição atual do cliente"
|
||||
trigger="Quando todas as conexões candidatas já estão em cooldown"
|
||||
effect="Aguarda no servidor e tenta novamente quando o primeiro cooldown expira"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -579,26 +582,26 @@ function WaitForCooldownCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
This only affects the current request. It does not write connection or provider state.
|
||||
Isso afeta apenas a requisição atual. Não grava estado de conexão nem de provedor.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Enable server-side waiting"
|
||||
description="When enabled, OmniRoute waits for the earliest cooldown and retries automatically."
|
||||
label="Ativar espera no servidor"
|
||||
description="Quando ativo, o OmniRoute aguarda o primeiro cooldown expirar e tenta novamente automaticamente."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Max retries"
|
||||
label="Máximo de tentativas"
|
||||
value={draft.maxRetries}
|
||||
min={0}
|
||||
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Max retry wait"
|
||||
label="Tempo máximo de espera por tentativa"
|
||||
value={draft.maxRetryWaitSec}
|
||||
min={0}
|
||||
suffix="sec"
|
||||
@@ -608,17 +611,17 @@ function WaitForCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Enable server-side waiting</div>
|
||||
<div className="text-xs text-text-muted">Ativar espera no servidor</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.enabled ? "Enabled" : "Disabled"}
|
||||
{value.enabled ? "Ativado" : "Desativado"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Max retries</div>
|
||||
<div className="text-xs text-text-muted">Máximo de tentativas</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Max retry wait</div>
|
||||
<div className="text-xs text-text-muted">Tempo máximo de espera por tentativa</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.maxRetryWaitSec}s
|
||||
</div>
|
||||
@@ -632,9 +635,19 @@ function WaitForCooldownCard({
|
||||
|
||||
export default function ResilienceTab() {
|
||||
const notify = useNotificationStore();
|
||||
const t = useTranslations("settings");
|
||||
const [data, setData] = useState<ResilienceResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingSection, setSavingSection] = useState<string | null>(null);
|
||||
const tx = useCallback(
|
||||
(key: string, fallback: string) => {
|
||||
if (typeof t.has === "function" && t.has(key as never)) {
|
||||
return t(key as never);
|
||||
}
|
||||
return fallback;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -654,7 +667,11 @@ export default function ResilienceTab() {
|
||||
waitForCooldown: json.waitForCooldown,
|
||||
});
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to load resilience settings");
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: tx("failedLoadResilience", "Failed to load resilience settings")
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
@@ -664,7 +681,7 @@ export default function ResilienceTab() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [notify]);
|
||||
}, [notify, tx]);
|
||||
|
||||
const savePatch = async (section: string, payload: Record<string, unknown>) => {
|
||||
setSavingSection(section);
|
||||
@@ -684,9 +701,13 @@ export default function ResilienceTab() {
|
||||
providerBreaker: json.providerBreaker,
|
||||
waitForCooldown: json.waitForCooldown,
|
||||
});
|
||||
notify.success("Resilience settings updated.");
|
||||
notify.success(tx("savedSuccessfully", "Resilience settings updated."));
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to save resilience settings");
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: tx("saveFailed", "Failed to save resilience settings")
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setSavingSection(null);
|
||||
@@ -698,7 +719,7 @@ export default function ResilienceTab() {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
Loading resilience settings...
|
||||
{tx("loadingResilience", "Loading resilience settings...")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -707,21 +728,29 @@ export default function ResilienceTab() {
|
||||
if (!data) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-text-muted">Unable to load resilience settings.</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{tx("failedLoadResilience", "Unable to load resilience settings.")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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">Resilience Structure</h2>
|
||||
<h2 className="text-lg font-bold text-text-main">
|
||||
{tx("resilienceStructureTitle", "Resilience Structure")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
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.
|
||||
{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>
|
||||
|
||||
53
src/app/api/resilience/model-cooldowns/route.ts
Normal file
53
src/app/api/resilience/model-cooldowns/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
clearModelUnavailability,
|
||||
getAvailabilityReport,
|
||||
resetAllAvailability,
|
||||
} from "@/domain/modelAvailability";
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const items = getAvailabilityReport().sort((a, b) => b.remainingMs - a.remainingMs);
|
||||
return NextResponse.json({ items });
|
||||
} catch (error: unknown) {
|
||||
console.error("[API] GET /api/resilience/model-cooldowns error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, "Failed to load cooldowns") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
all?: boolean;
|
||||
};
|
||||
|
||||
if (body.all) {
|
||||
resetAllAvailability();
|
||||
return NextResponse.json({ ok: true, clearedAll: true });
|
||||
}
|
||||
|
||||
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
||||
const model = typeof body.model === "string" ? body.model.trim() : "";
|
||||
if (!provider || !model) {
|
||||
return NextResponse.json({ error: "provider and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const removed = clearModelUnavailability(provider, model);
|
||||
return NextResponse.json({ ok: true, removed });
|
||||
} catch (error: unknown) {
|
||||
console.error("[API] DELETE /api/resilience/model-cooldowns error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, "Failed to clear cooldown") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3233,6 +3233,8 @@
|
||||
"autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.",
|
||||
"autoDisableThreshold": "Ban Threshold",
|
||||
"autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.",
|
||||
"resilienceStructureTitle": "Resilience Structure",
|
||||
"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.",
|
||||
"enableThinking": "Enable Thinking",
|
||||
"maxThinkingTokens": "Max Thinking Tokens",
|
||||
"enableProxy": "Enable Proxy",
|
||||
@@ -3795,7 +3797,11 @@
|
||||
"qdrantCleanupDesc": "Removes expired and old points based on",
|
||||
"searching": "Searching...",
|
||||
"cleaning": "Cleaning...",
|
||||
"cleanNow": "Clean now"
|
||||
"cleanNow": "Clean now",
|
||||
"optional": "Optional",
|
||||
"current": "Current",
|
||||
"remove": "Remove",
|
||||
"search": "Search"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",
|
||||
"cooldownCleared": "Cooldown limpo para {model}",
|
||||
"failedClearCooldown": "Falha ao limpar cooldown",
|
||||
"freeTier": "Plano gratuito",
|
||||
"loadingAvailability": "Carregando disponibilidade dos modelos...",
|
||||
"clearCooldown": "Limpar",
|
||||
"clearing": "Limpando...",
|
||||
@@ -2880,10 +2881,12 @@
|
||||
"timeoutMs": "Timeout (ms)",
|
||||
"enableSystemPrompt": "Ativar Prompt do Sistema",
|
||||
"systemPromptText": "Texto do Prompt do Sistema",
|
||||
"autoDisableBannedAccounts": "Auto-Disable Banned Accounts",
|
||||
"autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.",
|
||||
"autoDisableThreshold": "Ban Threshold",
|
||||
"autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.",
|
||||
"autoDisableBannedAccounts": "Auto-desativar contas banidas",
|
||||
"autoDisableDescription": "Marca permanentemente conexões de provedor como desativadas quando retornam sinais terminais de banimento (ex.: HTTP 403 'verify your account'). Isso remove a conexão da rotação de combos.",
|
||||
"autoDisableThreshold": "Limite de banimento",
|
||||
"autoDisableThresholdDesc": "Quantidade de sinais consecutivos de banimento antes da desativação permanente.",
|
||||
"resilienceStructureTitle": "Estrutura de resiliência",
|
||||
"resilienceStructureDesc": "Esta página configura apenas o comportamento. O estado ao vivo de circuit breaker aparece na página Saúde. Controle de retry específico por combo e slot de round-robin continuam nas configurações de combos.",
|
||||
"enableThinking": "Ativar Raciocínio",
|
||||
"maxThinkingTokens": "Máximo de Tokens de Raciocínio",
|
||||
"enableProxy": "Ativar Proxy",
|
||||
@@ -3469,7 +3472,11 @@
|
||||
"qdrantCleanupDesc": "Remove pontos expirados e antigos, baseado em",
|
||||
"searching": "Buscando...",
|
||||
"cleaning": "Limpando...",
|
||||
"cleanNow": "Limpar agora"
|
||||
"cleanNow": "Limpar agora",
|
||||
"optional": "Opcional",
|
||||
"current": "Atual",
|
||||
"remove": "Remover",
|
||||
"search": "Buscar"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "Motor RTK",
|
||||
|
||||
@@ -2374,6 +2374,7 @@
|
||||
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",
|
||||
"cooldownCleared": "Tempo de espera liberado para {model}",
|
||||
"failedClearCooldown": "Falha ao limpar o tempo de espera",
|
||||
"freeTier": "Plano gratuito",
|
||||
"loadingAvailability": "Carregando disponibilidade do modelo...",
|
||||
"clearCooldown": "Limpar",
|
||||
"clearing": "Limpando...",
|
||||
@@ -2834,10 +2835,12 @@
|
||||
"timeoutMs": "Tempo limite (ms)",
|
||||
"enableSystemPrompt": "Habilitar prompt do sistema",
|
||||
"systemPromptText": "Texto de prompt do sistema",
|
||||
"autoDisableBannedAccounts": "Auto-Disable Banned Accounts",
|
||||
"autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.",
|
||||
"autoDisableThreshold": "Ban Threshold",
|
||||
"autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.",
|
||||
"autoDisableBannedAccounts": "Auto-desativar contas banidas",
|
||||
"autoDisableDescription": "Marca permanentemente conexões de provedor como desativadas quando retornam sinais terminais de banimento (ex.: HTTP 403 'verify your account'). Isso remove a conexão da rotação de combos.",
|
||||
"autoDisableThreshold": "Limite de banimento",
|
||||
"autoDisableThresholdDesc": "Quantidade de sinais consecutivos de banimento antes da desativação permanente.",
|
||||
"resilienceStructureTitle": "Estrutura de resiliência",
|
||||
"resilienceStructureDesc": "Esta página configura apenas o comportamento. O estado ao vivo de circuit breaker aparece na página Saúde. Controle de retry específico por combo e slot de round-robin continuam nas configurações de combos.",
|
||||
"enableThinking": "Habilite o pensamento",
|
||||
"maxThinkingTokens": "Tokens de pensamento máximo",
|
||||
"enableProxy": "Habilitar proxy",
|
||||
@@ -3439,7 +3442,11 @@
|
||||
"qdrantCleanupDesc": "Remove pontos expirados e antigos, baseado em",
|
||||
"searching": "Buscando...",
|
||||
"cleaning": "Limpando...",
|
||||
"cleanNow": "Limpar agora"
|
||||
"cleanNow": "Limpar agora",
|
||||
"optional": "Opcional",
|
||||
"current": "Atual",
|
||||
"remove": "Remover",
|
||||
"search": "Buscar"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
Reference in New Issue
Block a user