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

View file

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