Преглед изворни кода

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.
MHSanaei пре 1 дан
родитељ
комит
f1760b0a28
1 измењених фајлова са 124 додато и 2 уклоњено
  1. 124 2
      frontend/src/pages/xray/BalancersTab.vue

+ 124 - 2
frontend/src/pages/xray/BalancersTab.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   PlusOutlined,
@@ -30,6 +30,30 @@ const STRATEGY_LABELS = {
   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 list = props.templateSettings?.routing?.balancers || [];
   return list.map((b, idx) => ({
@@ -83,6 +107,41 @@ function ensureBalancersArray() {
   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) {
   const out = {
     tag: form.tag,
@@ -115,6 +174,7 @@ function onConfirm(form) {
       }
     }
   }
+  syncObservatories();
   modalOpen.value = false;
 }
 
@@ -128,7 +188,10 @@ function confirmDelete(idx) {
     // 4 leaves the modal open if onOk returns a truthy non-thenable
     // (it expects a Promise to await), and splice() returns the array
     // 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: '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>
 
 <template>
@@ -192,6 +298,16 @@ const columns = computed(() => [
           </template>
         </template>
       </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>
 
     <BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
@@ -213,4 +329,10 @@ const columns = computed(() => [
 .danger {
   color: #ff4d4f;
 }
+
+.json-editor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  margin-top: 8px;
+}
 </style>