feat(xray/balancer): restore observatory editor + auto-sync selectors

The Vue3 migration dropped the Observatory / Burst Observatory section
that used to sit under the balancer table. Without it, leastPing /
leastLoad strategies had nowhere to populate Xray's required
subjectSelector, so balancers that depended on probe data silently
ran with an empty observer config.

- Auto-seed and sync `observatory` for leastPing balancers and
  `burstObservatory` for leastLoad balancers (subjectSelector
  recomputed from every matching balancer's selector list). Drops
  the observatory when no matching strategy remains.
- Defaults (probeURL, interval, connectivity, sampling) match the
  values the legacy panel shipped, themselves taken from the Xray
  docs at xtls.github.io/config/{observatory,burstobservatory}.html.
- Surface both observatories under the table as a radio-switched
  JSON textarea so admins can tune probe settings inline without
  dropping into the full xray template tab.
This commit is contained in:
MHSanaei 2026-05-11 00:11:09 +02:00
parent 745e394c74
commit f1760b0a28
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
PlusOutlined, PlusOutlined,
@ -30,6 +30,30 @@ const STRATEGY_LABELS = {
leastPing: 'Least ping', leastPing: 'Least ping',
}; };
// Observatory defaults values that the legacy panel seeded when a
// leastPing balancer first appeared. ProbeURL / interval follow Xray's
// own docs (https://xtls.github.io/config/observatory.html).
const DEFAULT_OBSERVATORY = Object.freeze({
subjectSelector: [],
probeURL: 'https://www.google.com/generate_204',
probeInterval: '1m',
enableConcurrency: true,
});
// BurstObservatory defaults seeded when a leastLoad balancer is
// configured. Hicloud's generate_204 is the same connectivity probe
// the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
subjectSelector: [],
pingConfig: {
destination: 'https://www.google.com/generate_204',
interval: '1m',
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
timeout: '5s',
sampling: 2,
},
});
const rows = computed(() => { const rows = computed(() => {
const list = props.templateSettings?.routing?.balancers || []; const list = props.templateSettings?.routing?.balancers || [];
return list.map((b, idx) => ({ return list.map((b, idx) => ({
@ -83,6 +107,41 @@ function ensureBalancersArray() {
return props.templateSettings.routing.balancers; return props.templateSettings.routing.balancers;
} }
// Keep observatory / burstObservatory in sync with the configured
// balancers. leastPing balancers feed Observatory's subjectSelector;
// leastLoad balancers feed BurstObservatory's. When the matching
// strategy disappears we drop the observatory entirely so the rendered
// xray config stays minimal.
function collectSelectors(list) {
const out = new Set();
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
return [...out];
}
function syncObservatories() {
const t = props.templateSettings;
if (!t) return;
const balancers = t.routing?.balancers || [];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
if (leastPings.length > 0) {
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
t.observatory.subjectSelector = collectSelectors(leastPings);
} else {
delete t.observatory;
}
const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
if (leastLoads.length > 0) {
if (!t.burstObservatory) {
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
}
t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
} else {
delete t.burstObservatory;
}
}
function buildWireBalancer(form) { function buildWireBalancer(form) {
const out = { const out = {
tag: form.tag, tag: form.tag,
@ -115,6 +174,7 @@ function onConfirm(form) {
} }
} }
} }
syncObservatories();
modalOpen.value = false; modalOpen.value = false;
} }
@ -128,7 +188,10 @@ function confirmDelete(idx) {
// 4 leaves the modal open if onOk returns a truthy non-thenable // 4 leaves the modal open if onOk returns a truthy non-thenable
// (it expects a Promise to await), and splice() returns the array // (it expects a Promise to await), and splice() returns the array
// of removed items. // of removed items.
onOk: () => { props.templateSettings.routing.balancers.splice(idx, 1); }, onOk: () => {
props.templateSettings.routing.balancers.splice(idx, 1);
syncObservatories();
},
}); });
} }
@ -139,6 +202,49 @@ const columns = computed(() => [
{ title: 'Selector', key: 'selector', align: 'center' }, { title: 'Selector', key: 'selector', align: 'center' },
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 }, { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
]); ]);
// === Observatory / BurstObservatory inline editor ====================
// The legacy panel surfaced both top-level observatory blocks here as a
// raw JSON editor so admins could tune probeURL / interval / sampling
// without having to drop into the full xray template tab. We keep that
// affordance but only render it when the matching observatory exists
// which is itself driven by syncObservatories() above.
const hasObservatory = computed(() => !!props.templateSettings?.observatory);
const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
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, () => {
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
obsView.value = 'burstObservatory';
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
obsView.value = 'observatory';
}
}, { immediate: true });
const obsText = computed({
get: () => {
const t = props.templateSettings;
if (!t) return '';
const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
return src ? JSON.stringify(src, null, 2) : '';
},
set: (next) => {
let parsed;
try { parsed = JSON.parse(next); } catch (_e) { return; }
if (!props.templateSettings) return;
if (obsView.value === 'observatory') {
props.templateSettings.observatory = parsed;
} else {
props.templateSettings.burstObservatory = parsed;
}
},
});
</script> </script>
<template> <template>
@ -192,6 +298,16 @@ const columns = computed(() => [
</template> </template>
</template> </template>
</a-table> </a-table>
<template v-if="showObsEditor">
<a-divider :style="{ margin: '8px 0' }" />
<a-radio-group v-model:value="obsView" button-style="solid" size="small">
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
</a-radio-group>
<a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
class="json-editor" />
</template>
</template> </template>
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags" <BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
@ -213,4 +329,10 @@ const columns = computed(() => [
.danger { .danger {
color: #ff4d4f; color: #ff4d4f;
} }
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
</style> </style>