feat(balancers): allow fallback on all strategies + feed burstObservatory from random/roundRobin

Drops the random/roundRobin gate on the Fallback field in
BalancerFormModal so every strategy can pick a fallback outbound.

syncObservatories now feeds burstObservatory from leastLoad +
random + roundRobin balancers (was leastLoad only), matching how
leastPing feeds observatory.

Fix the JsonEditor "Unexpected end of JSON input" that appeared
when switching a balancer between leastPing and another strategy:
the obsView watcher was gated on showObsEditor (a boolean OR of
the two flags) and missed the case where one observatory
swapped for the other in the same tick. Watch the individual
flags instead so obsView flips to the surviving editor and the
getter stops pointing at a deleted key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 23:41:07 +02:00
parent 2ff3c12a42
commit 5f98a2db72
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 14 additions and 22 deletions

View file

@ -61,16 +61,6 @@ const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value, () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
); );
const fallbackSupported = computed(
() => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
);
watch(() => form.strategy, (next) => {
if (next !== 'leastPing' && next !== 'leastLoad') {
form.fallbackTag = '';
}
});
const tagValidateStatus = computed(() => { const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error'; if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning'; if (duplicateTag.value) return 'warning';
@ -121,8 +111,8 @@ const okText = computed(() =>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Fallback" :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'"> <a-form-item label="Fallback">
<a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported"> <a-select v-model:value="form.fallbackTag" allow-clear>
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag"> <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }} {{ tag || `(${t('none')})` }}
</a-select-option> </a-select-option>

View file

@ -133,23 +133,25 @@ function syncObservatories() {
delete t.observatory; delete t.observatory;
} }
const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad'); const burstFeeders = balancers.filter((b) => {
if (leastLoads.length > 0) { const type = b.strategy?.type || 'random';
return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
});
if (burstFeeders.length > 0) {
if (!t.burstObservatory) { if (!t.burstObservatory) {
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY)); t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
} }
t.burstObservatory.subjectSelector = collectSelectors(leastLoads); t.burstObservatory.subjectSelector = collectSelectors(burstFeeders);
} else { } else {
delete t.burstObservatory; delete t.burstObservatory;
} }
} }
function buildWireBalancer(form) { function buildWireBalancer(form) {
const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
const out = { const out = {
tag: form.tag, tag: form.tag,
selector: [...form.selector], selector: [...form.selector],
fallbackTag: supportsFallback ? form.fallbackTag : '', fallbackTag: form.fallbackTag || '',
}; };
if (form.strategy && form.strategy !== 'random') { if (form.strategy && form.strategy !== 'random') {
out.strategy = { type: form.strategy }; out.strategy = { type: form.strategy };
@ -218,11 +220,11 @@ const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory
const obsView = ref('observatory'); const obsView = ref('observatory');
// Keep the radio selection valid as observatories appear/disappear // Watch each flag individually watching showObsEditor (OR of the two)
// e.g. deleting the last leastPing balancer should flip the editor to // misses the case where one observatory swaps for the other in the same
// the burstObservatory pane instead of leaving it pointing at the // tick, leaving obsView pointing at a now-deleted key and JsonEditor
// (now-removed) observatory key. // trying to parse an empty string.
watch(showObsEditor, () => { watch([hasObservatory, hasBurstObservatory], () => {
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) { if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
obsView.value = 'burstObservatory'; obsView.value = 'burstObservatory';
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) { } else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {