Skip to content

Commit b184f9f

Browse files
github-actions[bot]Pi Bot
andauthored
feat: add exponential backoff cooldown controls to Settings page (#349)
## Summary Fixes #345 Adds UI controls for the existing `initialMinutes` and `maxMinutes` exponential backoff settings in the Cooldown Manager, exposing them on the Configuration page. The new "Cooldown Settings" card follows the same pattern as the existing "Failover Settings" Disclosure card. ### Changes **Backend** (`packages/backend/src/routes/management/config.ts`): - Added `GET /v0/management/config/cooldown` — returns current cooldown policy (`initialMinutes`, `maxMinutes`) - Added `PATCH /v0/management/config/cooldown` — merges updates and persists via `ConfigService.setSetting` **Frontend API** (`packages/frontend/src/lib/api.ts`): - Added `getCooldownPolicy()` — fetches cooldown policy from backend - Added `patchCooldownPolicy()` — patches cooldown policy fields **Frontend UI** (`packages/frontend/src/pages/Config.tsx`): - Added "Cooldown Settings" Disclosure card with: - Description of the exponential backoff formula: `C(n) = min(C_max, C₀ × 2ⁿ)` - Numeric input for **Initial Cooldown** (`initialMinutes`, default 2) - Numeric input for **Maximum Cooldown** (`maxMinutes`, default 300) - Save button that persists changes via the API Co-authored-by: Pi Bot <pi-bot@plexus.dev>
1 parent 539cbbe commit b184f9f

3 files changed

Lines changed: 200 additions & 0 deletions

File tree

packages/backend/src/routes/management/config.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,45 @@ export async function registerConfigRoutes(
400400
}
401401
});
402402

403+
// ─── Cooldown Policy ──────────────────────────────────────────────
404+
405+
fastify.get('/v0/management/config/cooldown', async (_request, reply) => {
406+
try {
407+
const cooldown = await configService.getRepository().getCooldownPolicy();
408+
return reply.send(cooldown);
409+
} catch (e: any) {
410+
return reply.code(500).send({ error: 'Internal server error' });
411+
}
412+
});
413+
414+
fastify.patch('/v0/management/config/cooldown', async (request, reply) => {
415+
const body = request.body as Record<string, unknown> | null;
416+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
417+
return reply.code(400).send({ error: 'Object body is required' });
418+
}
419+
420+
try {
421+
// Read current values, merge with updates, and write back
422+
const current = await configService.getRepository().getCooldownPolicy();
423+
const merged = { ...current, ...body };
424+
425+
if (body.initialMinutes !== undefined) {
426+
await configService.setSetting('cooldown.initialMinutes', merged.initialMinutes);
427+
}
428+
if (body.maxMinutes !== undefined) {
429+
await configService.setSetting('cooldown.maxMinutes', merged.maxMinutes);
430+
}
431+
432+
// Return the final merged state
433+
const updated = await configService.getRepository().getCooldownPolicy();
434+
logger.debug('Cooldown policy updated via API');
435+
return reply.send(updated);
436+
} catch (e: any) {
437+
logger.error('Failed to patch cooldown config', e);
438+
return reply.code(500).send({ error: 'Internal server error' });
439+
}
440+
});
441+
403442
// ─── Vision Fallthrough ───────────────────────────────────────────
404443

405444
fastify.get('/v0/management/config/vision-fallthrough', async (_request, reply) => {

packages/frontend/src/lib/api.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3056,4 +3056,33 @@ export const api = {
30563056
if (!res.ok) throw new Error('Failed to update failover policy');
30573057
return res.json();
30583058
},
3059+
3060+
// ─── Cooldown Settings ──────────────────────────────────────────────
3061+
3062+
/** Fetch current cooldown policy. */
3063+
getCooldownPolicy: async (): Promise<{
3064+
initialMinutes: number;
3065+
maxMinutes: number;
3066+
}> => {
3067+
const res = await fetchWithAuth(`${API_BASE}/v0/management/config/cooldown`);
3068+
if (!res.ok) throw new Error('Failed to fetch cooldown policy');
3069+
return res.json();
3070+
},
3071+
3072+
/** Patch cooldown policy fields. */
3073+
patchCooldownPolicy: async (updates: {
3074+
initialMinutes?: number;
3075+
maxMinutes?: number;
3076+
}): Promise<{
3077+
initialMinutes: number;
3078+
maxMinutes: number;
3079+
}> => {
3080+
const res = await fetchWithAuth(`${API_BASE}/v0/management/config/cooldown`, {
3081+
method: 'PATCH',
3082+
headers: { 'Content-Type': 'application/json' },
3083+
body: JSON.stringify(updates),
3084+
});
3085+
if (!res.ok) throw new Error('Failed to update cooldown policy');
3086+
return res.json();
3087+
},
30593088
};

packages/frontend/src/pages/Config.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Archive,
1212
Shield,
1313
Save,
14+
Timer,
1415
} from 'lucide-react';
1516
import { api } from '../lib/api';
1617
import { useToast } from '../contexts/ToastContext';
@@ -56,12 +57,22 @@ interface FailoverPolicy {
5657
retryableErrors: string[];
5758
}
5859

60+
interface CooldownPolicy {
61+
initialMinutes: number;
62+
maxMinutes: number;
63+
}
64+
5965
const DEFAULT_FAILOVER_POLICY: FailoverPolicy = {
6066
enabled: true,
6167
retryableStatusCodes: [],
6268
retryableErrors: [],
6369
};
6470

71+
const DEFAULT_COOLDOWN_POLICY: CooldownPolicy = {
72+
initialMinutes: 2,
73+
maxMinutes: 300,
74+
};
75+
6576
export const Config = () => {
6677
const toast = useToast();
6778
const [config, setConfig] = useState('');
@@ -79,6 +90,11 @@ export const Config = () => {
7990
const [statusCodesText, setStatusCodesText] = useState('');
8091
const [errorsText, setErrorsText] = useState('');
8192

93+
// Cooldown settings state
94+
const [cooldownPolicy, setCooldownPolicy] = useState<CooldownPolicy>(DEFAULT_COOLDOWN_POLICY);
95+
const [cooldownLoaded, setCooldownLoaded] = useState(false);
96+
const [cooldownSaving, setCooldownSaving] = useState(false);
97+
8298
const loadFailoverPolicy = useCallback(async () => {
8399
try {
84100
const policy = await api.getFailoverPolicy();
@@ -92,6 +108,17 @@ export const Config = () => {
92108
}
93109
}, [toast]);
94110

111+
const loadCooldownPolicy = useCallback(async () => {
112+
try {
113+
const policy = await api.getCooldownPolicy();
114+
setCooldownPolicy(policy);
115+
setCooldownLoaded(true);
116+
} catch (e) {
117+
console.error('Failed to load cooldown policy:', e);
118+
toast.error('Failed to load cooldown settings');
119+
}
120+
}, [toast]);
121+
95122
const handleSaveFailover = async () => {
96123
setFailoverSaving(true);
97124
try {
@@ -126,6 +153,23 @@ export const Config = () => {
126153
}
127154
};
128155

156+
const handleSaveCooldown = async () => {
157+
setCooldownSaving(true);
158+
try {
159+
const updated = await api.patchCooldownPolicy({
160+
initialMinutes: cooldownPolicy.initialMinutes,
161+
maxMinutes: cooldownPolicy.maxMinutes,
162+
});
163+
164+
setCooldownPolicy(updated);
165+
toast.success('Cooldown settings saved');
166+
} catch (e) {
167+
toast.error((e as Error).message, 'Failed to save cooldown settings');
168+
} finally {
169+
setCooldownSaving(false);
170+
}
171+
};
172+
129173
const loadConfig = async () => {
130174
try {
131175
const data = await api.getConfigExport();
@@ -141,6 +185,7 @@ export const Config = () => {
141185
useEffect(() => {
142186
loadConfig();
143187
loadFailoverPolicy();
188+
loadCooldownPolicy();
144189
// eslint-disable-next-line react-hooks/exhaustive-deps
145190
}, []);
146191

@@ -460,6 +505,93 @@ export const Config = () => {
460505
</div>
461506
</Disclosure>
462507

508+
{/* ─── Cooldown Settings ──────────────────────────────────── */}
509+
<Disclosure
510+
title="Cooldown Settings"
511+
defaultOpen={false}
512+
extra={
513+
<Button
514+
variant="primary"
515+
size="sm"
516+
onClick={handleSaveCooldown}
517+
isLoading={cooldownSaving}
518+
disabled={!cooldownLoaded}
519+
leftIcon={<Save size={14} />}
520+
>
521+
Save
522+
</Button>
523+
}
524+
>
525+
<div className="flex flex-col gap-5">
526+
{/* Exponential Backoff description */}
527+
<div className="flex items-center gap-2">
528+
<Timer size={16} className="text-primary" />
529+
<div>
530+
<p className="text-sm font-medium text-text">Exponential Backoff</p>
531+
<p className="text-xs text-text-muted">
532+
When a provider fails, it is placed on cooldown using exponential backoff:{' '}
533+
<code className="text-text-secondary">C(n) = min(C_max, C₀ × 2ⁿ)</code> where n is
534+
the consecutive failure count.
535+
</p>
536+
</div>
537+
</div>
538+
539+
{/* Initial Minutes */}
540+
<div>
541+
<label
542+
htmlFor="cooldownInitialMinutes"
543+
className="block text-sm font-medium text-text mb-1"
544+
>
545+
Initial Cooldown (minutes)
546+
</label>
547+
<p className="text-xs text-text-muted mb-2">
548+
C₀ — the cooldown duration after the first failure. Subsequent failures double the
549+
duration until the maximum is reached.
550+
</p>
551+
<input
552+
id="cooldownInitialMinutes"
553+
type="number"
554+
min={1}
555+
value={cooldownPolicy.initialMinutes}
556+
onChange={(e) =>
557+
setCooldownPolicy((prev) => ({
558+
...prev,
559+
initialMinutes: Math.max(1, Number(e.target.value) || 1),
560+
}))
561+
}
562+
className="w-full max-w-[200px] rounded-md border border-border bg-bg-glass px-3 py-2 text-sm text-text font-mono placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
563+
/>
564+
</div>
565+
566+
{/* Max Minutes */}
567+
<div>
568+
<label
569+
htmlFor="cooldownMaxMinutes"
570+
className="block text-sm font-medium text-text mb-1"
571+
>
572+
Maximum Cooldown (minutes)
573+
</label>
574+
<p className="text-xs text-text-muted mb-2">
575+
C_max — the upper limit for any cooldown duration, regardless of how many
576+
consecutive failures have occurred.
577+
</p>
578+
<input
579+
id="cooldownMaxMinutes"
580+
type="number"
581+
min={1}
582+
value={cooldownPolicy.maxMinutes}
583+
onChange={(e) =>
584+
setCooldownPolicy((prev) => ({
585+
...prev,
586+
maxMinutes: Math.max(1, Number(e.target.value) || 1),
587+
}))
588+
}
589+
className="w-full max-w-[200px] rounded-md border border-border bg-bg-glass px-3 py-2 text-sm text-text font-mono placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
590+
/>
591+
</div>
592+
</div>
593+
</Disclosure>
594+
463595
<Card
464596
title="Backup & Restore"
465597
extra={

0 commit comments

Comments
 (0)