Skip to content

Commit 92c6a40

Browse files
feat: add Exploration Rate settings to the UI (#350)
## Summary Fixes #346 — Exposes `performanceExplorationRate` and `latencyExplorationRate` in the Configuration page, following the same Disclosure-card style as the existing Failover and Cooldown Settings cards. ### Changes **Backend** (`packages/backend/src/routes/management/config.ts`): - Added `GET /v0/management/config/exploration-rate` — returns both rates - Added `PATCH /v0/management/config/exploration-rate` — validates values are 0–1, persists via `ConfigService.setSetting` **Frontend API** (`packages/frontend/src/lib/api.ts`): - Added `getExplorationRates()` — fetches current rates - Added `patchExplorationRates(updates)` — patches one or both rates **Frontend UI** (`packages/frontend/src/pages/Config.tsx`): - Added `ExplorationRates` interface and default values - Added state management, load/save handlers, and `loadExplorationRates` on mount - Added "Exploration Rate Settings" Disclosure card with: - Compass icon + description text - "Performance Exploration Rate" number input (0–1, step 0.01) - "Latency Exploration Rate" number input (0–1, step 0.01), with note that it defaults to the performance rate - Save button matching the failover/cooldown card style Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 52ce4ce commit 92c6a40

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,66 @@ export async function registerConfigRoutes(
439439
}
440440
});
441441

442+
// ─── Exploration Rate ─────────────────────────────────────────────
443+
444+
fastify.get('/v0/management/config/exploration-rate', async (_request, reply) => {
445+
try {
446+
const performanceExplorationRate = await configService.getSetting<number>(
447+
'performanceExplorationRate',
448+
0.05
449+
);
450+
const latencyExplorationRate = await configService.getSetting<number>(
451+
'latencyExplorationRate',
452+
0.05
453+
);
454+
return reply.send({ performanceExplorationRate, latencyExplorationRate });
455+
} catch (e: any) {
456+
return reply.code(500).send({ error: 'Internal server error' });
457+
}
458+
});
459+
460+
fastify.patch('/v0/management/config/exploration-rate', async (request, reply) => {
461+
const body = request.body as Record<string, unknown> | null;
462+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
463+
return reply.code(400).send({ error: 'Object body is required' });
464+
}
465+
466+
try {
467+
if (body.performanceExplorationRate !== undefined) {
468+
const value = Number(body.performanceExplorationRate);
469+
if (!Number.isFinite(value) || value < 0 || value > 1) {
470+
return reply
471+
.code(400)
472+
.send({ error: 'performanceExplorationRate must be a number between 0 and 1' });
473+
}
474+
await configService.setSetting('performanceExplorationRate', value);
475+
}
476+
if (body.latencyExplorationRate !== undefined) {
477+
const value = Number(body.latencyExplorationRate);
478+
if (!Number.isFinite(value) || value < 0 || value > 1) {
479+
return reply
480+
.code(400)
481+
.send({ error: 'latencyExplorationRate must be a number between 0 and 1' });
482+
}
483+
await configService.setSetting('latencyExplorationRate', value);
484+
}
485+
486+
const performanceExplorationRate = await configService.getSetting<number>(
487+
'performanceExplorationRate',
488+
0.05
489+
);
490+
const latencyExplorationRate = await configService.getSetting<number>(
491+
'latencyExplorationRate',
492+
0.05
493+
);
494+
logger.debug('Exploration rate settings updated via API');
495+
return reply.send({ performanceExplorationRate, latencyExplorationRate });
496+
} catch (e: any) {
497+
logger.error('Failed to patch exploration rate config', e);
498+
return reply.code(500).send({ error: 'Internal server error' });
499+
}
500+
});
501+
442502
// ─── Vision Fallthrough ───────────────────────────────────────────
443503

444504
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
@@ -3085,4 +3085,33 @@ export const api = {
30853085
if (!res.ok) throw new Error('Failed to update cooldown policy');
30863086
return res.json();
30873087
},
3088+
3089+
// ─── Exploration Rate Settings ─────────────────────────────────────
3090+
3091+
/** Fetch current exploration rate settings. */
3092+
getExplorationRates: async (): Promise<{
3093+
performanceExplorationRate: number;
3094+
latencyExplorationRate: number;
3095+
}> => {
3096+
const res = await fetchWithAuth(`${API_BASE}/v0/management/config/exploration-rate`);
3097+
if (!res.ok) throw new Error('Failed to fetch exploration rate settings');
3098+
return res.json();
3099+
},
3100+
3101+
/** Patch exploration rate settings. */
3102+
patchExplorationRates: async (updates: {
3103+
performanceExplorationRate?: number;
3104+
latencyExplorationRate?: number;
3105+
}): Promise<{
3106+
performanceExplorationRate: number;
3107+
latencyExplorationRate: number;
3108+
}> => {
3109+
const res = await fetchWithAuth(`${API_BASE}/v0/management/config/exploration-rate`, {
3110+
method: 'PATCH',
3111+
headers: { 'Content-Type': 'application/json' },
3112+
body: JSON.stringify(updates),
3113+
});
3114+
if (!res.ok) throw new Error('Failed to update exploration rate settings');
3115+
return res.json();
3116+
},
30883117
};

packages/frontend/src/pages/Config.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Shield,
1313
Save,
1414
Timer,
15+
Compass,
1516
} from 'lucide-react';
1617
import { api } from '../lib/api';
1718
import { useToast } from '../contexts/ToastContext';
@@ -62,6 +63,16 @@ interface CooldownPolicy {
6263
maxMinutes: number;
6364
}
6465

66+
interface ExplorationRates {
67+
performanceExplorationRate: number;
68+
latencyExplorationRate: number;
69+
}
70+
71+
const DEFAULT_EXPLORATION_RATES: ExplorationRates = {
72+
performanceExplorationRate: 0.05,
73+
latencyExplorationRate: 0.05,
74+
};
75+
6576
const DEFAULT_FAILOVER_POLICY: FailoverPolicy = {
6677
enabled: true,
6778
retryableStatusCodes: [],
@@ -95,6 +106,12 @@ export const Config = () => {
95106
const [cooldownLoaded, setCooldownLoaded] = useState(false);
96107
const [cooldownSaving, setCooldownSaving] = useState(false);
97108

109+
// Exploration rate settings state
110+
const [explorationRates, setExplorationRates] =
111+
useState<ExplorationRates>(DEFAULT_EXPLORATION_RATES);
112+
const [explorationLoaded, setExplorationLoaded] = useState(false);
113+
const [explorationSaving, setExplorationSaving] = useState(false);
114+
98115
const loadFailoverPolicy = useCallback(async () => {
99116
try {
100117
const policy = await api.getFailoverPolicy();
@@ -119,6 +136,17 @@ export const Config = () => {
119136
}
120137
}, [toast]);
121138

139+
const loadExplorationRates = useCallback(async () => {
140+
try {
141+
const rates = await api.getExplorationRates();
142+
setExplorationRates(rates);
143+
setExplorationLoaded(true);
144+
} catch (e) {
145+
console.error('Failed to load exploration rates:', e);
146+
toast.error('Failed to load exploration rate settings');
147+
}
148+
}, [toast]);
149+
122150
const handleSaveFailover = async () => {
123151
setFailoverSaving(true);
124152
try {
@@ -170,6 +198,23 @@ export const Config = () => {
170198
}
171199
};
172200

201+
const handleSaveExplorationRates = async () => {
202+
setExplorationSaving(true);
203+
try {
204+
const updated = await api.patchExplorationRates({
205+
performanceExplorationRate: explorationRates.performanceExplorationRate,
206+
latencyExplorationRate: explorationRates.latencyExplorationRate,
207+
});
208+
209+
setExplorationRates(updated);
210+
toast.success('Exploration rate settings saved');
211+
} catch (e) {
212+
toast.error((e as Error).message, 'Failed to save exploration rate settings');
213+
} finally {
214+
setExplorationSaving(false);
215+
}
216+
};
217+
173218
const loadConfig = async () => {
174219
try {
175220
const data = await api.getConfigExport();
@@ -186,6 +231,7 @@ export const Config = () => {
186231
loadConfig();
187232
loadFailoverPolicy();
188233
loadCooldownPolicy();
234+
loadExplorationRates();
189235
// eslint-disable-next-line react-hooks/exhaustive-deps
190236
}, []);
191237

@@ -592,6 +638,100 @@ export const Config = () => {
592638
</div>
593639
</Disclosure>
594640

641+
{/* ─── Exploration Rate Settings ────────────────────────────── */}
642+
<Disclosure
643+
title="Exploration Rate Settings"
644+
defaultOpen={false}
645+
extra={
646+
<Button
647+
variant="primary"
648+
size="sm"
649+
onClick={handleSaveExplorationRates}
650+
isLoading={explorationSaving}
651+
disabled={!explorationLoaded}
652+
leftIcon={<Save size={14} />}
653+
>
654+
Save
655+
</Button>
656+
}
657+
>
658+
<div className="flex flex-col gap-5">
659+
{/* Exploration Rate description */}
660+
<div className="flex items-center gap-2">
661+
<Compass size={16} className="text-primary" />
662+
<div>
663+
<p className="text-sm font-medium text-text">Provider Exploration</p>
664+
<p className="text-xs text-text-muted">
665+
Exploration rate controls how often the selector picks a non-optimal provider to
666+
discover better options. A value of 0 always selects the best-known provider; a
667+
value of 1 picks randomly. Applies to performance and latency selectors.
668+
</p>
669+
</div>
670+
</div>
671+
672+
{/* Performance Exploration Rate */}
673+
<div>
674+
<label
675+
htmlFor="performanceExplorationRate"
676+
className="block text-sm font-medium text-text mb-1"
677+
>
678+
Performance Exploration Rate
679+
</label>
680+
<p className="text-xs text-text-muted mb-2">
681+
The probability of exploring a non-optimal provider when using the performance
682+
selector. Default: 0.05 (5%).
683+
</p>
684+
<input
685+
id="performanceExplorationRate"
686+
type="number"
687+
min={0}
688+
max={1}
689+
step={0.01}
690+
value={explorationRates.performanceExplorationRate}
691+
onChange={(e) =>
692+
setExplorationRates((prev) => ({
693+
...prev,
694+
performanceExplorationRate: Math.min(
695+
1,
696+
Math.max(0, Number(e.target.value) || 0)
697+
),
698+
}))
699+
}
700+
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"
701+
/>
702+
</div>
703+
704+
{/* Latency Exploration Rate */}
705+
<div>
706+
<label
707+
htmlFor="latencyExplorationRate"
708+
className="block text-sm font-medium text-text mb-1"
709+
>
710+
Latency Exploration Rate
711+
</label>
712+
<p className="text-xs text-text-muted mb-2">
713+
The probability of exploring a non-optimal provider when using the latency selector.
714+
Defaults to the Performance Exploration Rate if not explicitly set.
715+
</p>
716+
<input
717+
id="latencyExplorationRate"
718+
type="number"
719+
min={0}
720+
max={1}
721+
step={0.01}
722+
value={explorationRates.latencyExplorationRate}
723+
onChange={(e) =>
724+
setExplorationRates((prev) => ({
725+
...prev,
726+
latencyExplorationRate: Math.min(1, Math.max(0, Number(e.target.value) || 0)),
727+
}))
728+
}
729+
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"
730+
/>
731+
</div>
732+
</div>
733+
</Disclosure>
734+
595735
<Card
596736
title="Backup & Restore"
597737
extra={

0 commit comments

Comments
 (0)