Skip to content

Commit 56a2673

Browse files
github-actions[bot]Pipi-assistant
authored
feat: expose failover settings in the Settings UI (#347)
## Summary Fixes #344 Adds a "Failover Settings" card to the Settings (Configuration) page, exposing the three failover policy fields that previously had no UI: - **enabled** — Toggle switch to enable/disable failover - **retryableStatusCodes** — Textarea for comma-separated HTTP status codes that trigger retry - **retryableErrors** — Textarea for comma-separated network error codes that trigger retry ## Changes ### Backend (`packages/backend`) - **`src/routes/management/config.ts`**: Added `GET /v0/management/config/failover` and `PATCH /v0/management/config/failover` endpoints, following the same pattern as the existing `vision-fallthrough` endpoints. ### Frontend (`packages/frontend`) - **`src/lib/api.ts`**: Added `getFailoverPolicy()` and `patchFailoverPolicy()` API helpers. - **`src/pages/Config.tsx`**: Added a "Failover Settings" card at the top of the settings page with: - Toggle switch for enabling/disabling failover - Textarea for editing retryable status codes (validated 100–599) - Textarea for editing retryable network errors - Save button that persists changes via the API ## Testing - TypeScript compilation passes for both backend and frontend - Existing dispatcher-failover tests (21/21) pass - Backend endpoint reuses existing `config-repository.getFailoverPolicy()` and `configService.setSettingsBulk()` infrastructure --------- Co-authored-by: Pi <pi@plexus.dev> Co-authored-by: pi-assistant <pi-assistant[bot]@users.noreply.github.com>
1 parent 0e743f2 commit 56a2673

3 files changed

Lines changed: 229 additions & 1 deletion

File tree

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,51 @@ export async function registerConfigRoutes(
355355
}
356356
});
357357

358+
// ─── Failover Policy ─────────────────────────────────────────────
359+
360+
fastify.get('/v0/management/config/failover', async (_request, reply) => {
361+
try {
362+
const failover = await configService.getRepository().getFailoverPolicy();
363+
return reply.send(failover);
364+
} catch (e: any) {
365+
return reply.code(500).send({ error: 'Internal server error' });
366+
}
367+
});
368+
369+
fastify.patch('/v0/management/config/failover', async (request, reply) => {
370+
const body = request.body as Record<string, unknown> | null;
371+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
372+
return reply.code(400).send({ error: 'Object body is required' });
373+
}
374+
375+
try {
376+
// Read current values, merge with updates, and write back
377+
const current = await configService.getRepository().getFailoverPolicy();
378+
const merged = { ...current, ...body };
379+
380+
if (body.enabled !== undefined) {
381+
await configService.setSetting('failover.enabled', merged.enabled);
382+
}
383+
if (body.retryableStatusCodes !== undefined) {
384+
await configService.setSetting(
385+
'failover.retryableStatusCodes',
386+
merged.retryableStatusCodes
387+
);
388+
}
389+
if (body.retryableErrors !== undefined) {
390+
await configService.setSetting('failover.retryableErrors', merged.retryableErrors);
391+
}
392+
393+
// Return the final merged state
394+
const updated = await configService.getRepository().getFailoverPolicy();
395+
logger.debug('Failover policy updated via API');
396+
return reply.send(updated);
397+
} catch (e: any) {
398+
logger.error('Failed to patch failover config', e);
399+
return reply.code(500).send({ error: 'Internal server error' });
400+
}
401+
});
402+
358403
// ─── Vision Fallthrough ───────────────────────────────────────────
359404

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

packages/frontend/src/lib/api.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3024,4 +3024,36 @@ export const api = {
30243024
}
30253025
return res.json();
30263026
},
3027+
3028+
// ─── Failover Settings ────────────────────────────────────────────
3029+
3030+
/** Fetch current failover policy. */
3031+
getFailoverPolicy: async (): Promise<{
3032+
enabled: boolean;
3033+
retryableStatusCodes: number[];
3034+
retryableErrors: string[];
3035+
}> => {
3036+
const res = await fetchWithAuth(`${API_BASE}/v0/management/config/failover`);
3037+
if (!res.ok) throw new Error('Failed to fetch failover policy');
3038+
return res.json();
3039+
},
3040+
3041+
/** Patch failover policy fields. */
3042+
patchFailoverPolicy: async (updates: {
3043+
enabled?: boolean;
3044+
retryableStatusCodes?: number[];
3045+
retryableErrors?: string[];
3046+
}): Promise<{
3047+
enabled: boolean;
3048+
retryableStatusCodes: number[];
3049+
retryableErrors: string[];
3050+
}> => {
3051+
const res = await fetchWithAuth(`${API_BASE}/v0/management/config/failover`, {
3052+
method: 'PATCH',
3053+
headers: { 'Content-Type': 'application/json' },
3054+
body: JSON.stringify(updates),
3055+
});
3056+
if (!res.ok) throw new Error('Failed to update failover policy');
3057+
return res.json();
3058+
},
30273059
};

packages/frontend/src/pages/Config.tsx

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, useEffect, useRef, useState } from 'react';
1+
import { Component, useEffect, useRef, useState, useCallback } from 'react';
22
import type { ErrorInfo, ReactNode } from 'react';
33
import Editor from '@monaco-editor/react';
44
import {
@@ -9,11 +9,15 @@ import {
99
RefreshCw,
1010
HardDrive,
1111
Archive,
12+
Shield,
13+
Save,
1214
} from 'lucide-react';
1315
import { api } from '../lib/api';
1416
import { useToast } from '../contexts/ToastContext';
1517
import { Card } from '../components/ui/Card';
1618
import { Button } from '../components/ui/Button';
19+
import { Switch } from '../components/ui/Switch';
20+
import { Disclosure } from '../components/ui/Disclosure';
1721
import { PageHeader } from '../components/layout/PageHeader';
1822
import { PageContainer } from '../components/layout/PageContainer';
1923
import type { CardLayout } from '../types/card';
@@ -46,6 +50,18 @@ class EditorErrorBoundary extends Component<{ children: ReactNode }, { error: Er
4650
}
4751
}
4852

53+
interface FailoverPolicy {
54+
enabled: boolean;
55+
retryableStatusCodes: number[];
56+
retryableErrors: string[];
57+
}
58+
59+
const DEFAULT_FAILOVER_POLICY: FailoverPolicy = {
60+
enabled: true,
61+
retryableStatusCodes: [],
62+
retryableErrors: [],
63+
};
64+
4965
export const Config = () => {
5066
const toast = useToast();
5167
const [config, setConfig] = useState('');
@@ -56,6 +72,60 @@ export const Config = () => {
5672
const [isRestoreLoading, setIsRestoreLoading] = useState(false);
5773
const restoreInputRef = useRef<HTMLInputElement>(null);
5874

75+
// Failover settings state
76+
const [failoverPolicy, setFailoverPolicy] = useState<FailoverPolicy>(DEFAULT_FAILOVER_POLICY);
77+
const [failoverLoaded, setFailoverLoaded] = useState(false);
78+
const [failoverSaving, setFailoverSaving] = useState(false);
79+
const [statusCodesText, setStatusCodesText] = useState('');
80+
const [errorsText, setErrorsText] = useState('');
81+
82+
const loadFailoverPolicy = useCallback(async () => {
83+
try {
84+
const policy = await api.getFailoverPolicy();
85+
setFailoverPolicy(policy);
86+
setStatusCodesText(policy.retryableStatusCodes.join(', '));
87+
setErrorsText(policy.retryableErrors.join(', '));
88+
setFailoverLoaded(true);
89+
} catch (e) {
90+
console.error('Failed to load failover policy:', e);
91+
toast.error('Failed to load failover settings');
92+
}
93+
}, [toast]);
94+
95+
const handleSaveFailover = async () => {
96+
setFailoverSaving(true);
97+
try {
98+
// Parse status codes
99+
const statusCodes = statusCodesText
100+
.split(/[\s,]+/)
101+
.map((s) => s.trim())
102+
.filter(Boolean)
103+
.map(Number)
104+
.filter((n) => Number.isInteger(n) && n >= 100 && n <= 599);
105+
106+
// Parse error codes
107+
const retryableErrors = errorsText
108+
.split(/[\s,]+/)
109+
.map((s) => s.trim())
110+
.filter(Boolean);
111+
112+
const updated = await api.patchFailoverPolicy({
113+
enabled: failoverPolicy.enabled,
114+
retryableStatusCodes: statusCodes,
115+
retryableErrors,
116+
});
117+
118+
setFailoverPolicy(updated);
119+
setStatusCodesText(updated.retryableStatusCodes.join(', '));
120+
setErrorsText(updated.retryableErrors.join(', '));
121+
toast.success('Failover settings saved');
122+
} catch (e) {
123+
toast.error((e as Error).message, 'Failed to save failover settings');
124+
} finally {
125+
setFailoverSaving(false);
126+
}
127+
};
128+
59129
const loadConfig = async () => {
60130
try {
61131
const data = await api.getConfigExport();
@@ -70,6 +140,7 @@ export const Config = () => {
70140

71141
useEffect(() => {
72142
loadConfig();
143+
loadFailoverPolicy();
73144
// eslint-disable-next-line react-hooks/exhaustive-deps
74145
}, []);
75146

@@ -309,6 +380,86 @@ export const Config = () => {
309380
</div>
310381
</Card>
311382

383+
{/* ─── Failover Settings ──────────────────────────────────── */}
384+
<Disclosure
385+
title="Failover Settings"
386+
defaultOpen={false}
387+
extra={
388+
<Button
389+
variant="primary"
390+
size="sm"
391+
onClick={handleSaveFailover}
392+
isLoading={failoverSaving}
393+
disabled={!failoverLoaded}
394+
leftIcon={<Save size={14} />}
395+
>
396+
Save
397+
</Button>
398+
}
399+
>
400+
<div className="flex flex-col gap-5">
401+
{/* Enabled toggle */}
402+
<div className="flex items-center justify-between">
403+
<div className="flex items-center gap-2">
404+
<Shield size={16} className="text-primary" />
405+
<div>
406+
<p className="text-sm font-medium text-text">Enable Failover</p>
407+
<p className="text-xs text-text-muted">
408+
When enabled, failed requests are automatically retried on the next available
409+
provider.
410+
</p>
411+
</div>
412+
</div>
413+
<Switch
414+
checked={failoverPolicy.enabled}
415+
onChange={(checked) => setFailoverPolicy((prev) => ({ ...prev, enabled: checked }))}
416+
aria-label="Toggle failover on/off"
417+
/>
418+
</div>
419+
420+
{/* Retryable Status Codes */}
421+
<div>
422+
<label
423+
htmlFor="retryableStatusCodes"
424+
className="block text-sm font-medium text-text mb-1"
425+
>
426+
Retryable Status Codes
427+
</label>
428+
<p className="text-xs text-text-muted mb-2">
429+
HTTP status codes that trigger a retry on the next provider. Enter comma-separated
430+
values (100–599). Defaults to all non-2xx codes except 413 and 422 when empty.
431+
</p>
432+
<textarea
433+
id="retryableStatusCodes"
434+
value={statusCodesText}
435+
onChange={(e) => setStatusCodesText(e.target.value)}
436+
placeholder="e.g. 429, 500, 502, 503"
437+
rows={3}
438+
className="w-full 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 resize-y"
439+
/>
440+
</div>
441+
442+
{/* Retryable Errors */}
443+
<div>
444+
<label htmlFor="retryableErrors" className="block text-sm font-medium text-text mb-1">
445+
Retryable Network Errors
446+
</label>
447+
<p className="text-xs text-text-muted mb-2">
448+
Network error codes that trigger a retry on the next provider. Enter comma-separated
449+
values. Defaults to ECONNREFUSED, ETIMEDOUT, ENOTFOUND when empty.
450+
</p>
451+
<textarea
452+
id="retryableErrors"
453+
value={errorsText}
454+
onChange={(e) => setErrorsText(e.target.value)}
455+
placeholder="e.g. ECONNREFUSED, ETIMEDOUT, ENOTFOUND"
456+
rows={2}
457+
className="w-full 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 resize-y"
458+
/>
459+
</div>
460+
</div>
461+
</Disclosure>
462+
312463
<Card
313464
title="Backup & Restore"
314465
extra={

0 commit comments

Comments
 (0)