BalancersTab.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import {
  5. PlusOutlined,
  6. MoreOutlined,
  7. EditOutlined,
  8. DeleteOutlined,
  9. } from '@ant-design/icons-vue';
  10. import { Modal } from 'ant-design-vue';
  11. import BalancerFormModal from './BalancerFormModal.vue';
  12. const { t } = useI18n();
  13. // Balancers tab — list + add/edit/delete over
  14. // templateSettings.routing.balancers. The legacy panel kept the wire
  15. // shape's `strategy: { type: 'random' }` nesting only when non-default;
  16. // we follow the same convention on submit.
  17. const props = defineProps({
  18. templateSettings: { type: Object, default: null },
  19. clientReverseTags: { type: Array, default: () => [] },
  20. });
  21. const STRATEGY_LABELS = {
  22. random: 'Random',
  23. roundRobin: 'Round robin',
  24. leastLoad: 'Least load',
  25. leastPing: 'Least ping',
  26. };
  27. // Observatory defaults — values that the legacy panel seeded when a
  28. // leastPing balancer first appeared. ProbeURL / interval follow Xray's
  29. // own docs (https://xtls.github.io/config/observatory.html).
  30. const DEFAULT_OBSERVATORY = Object.freeze({
  31. subjectSelector: [],
  32. probeURL: 'https://www.google.com/generate_204',
  33. probeInterval: '1m',
  34. enableConcurrency: true,
  35. });
  36. // BurstObservatory defaults — seeded when a leastLoad balancer is
  37. // configured. Hicloud's generate_204 is the same connectivity probe
  38. // the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
  39. const DEFAULT_BURST_OBSERVATORY = Object.freeze({
  40. subjectSelector: [],
  41. pingConfig: {
  42. destination: 'https://www.google.com/generate_204',
  43. interval: '1m',
  44. connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
  45. timeout: '5s',
  46. sampling: 2,
  47. },
  48. });
  49. const rows = computed(() => {
  50. const list = props.templateSettings?.routing?.balancers || [];
  51. return list.map((b, idx) => ({
  52. key: idx,
  53. tag: b.tag || '',
  54. strategy: b.strategy?.type || 'random',
  55. selector: b.selector || [],
  56. fallbackTag: b.fallbackTag || '',
  57. }));
  58. });
  59. const outboundTags = computed(() => {
  60. const tags = new Set();
  61. for (const o of props.templateSettings?.outbounds || []) {
  62. if (o.tag) tags.add(o.tag);
  63. }
  64. for (const t of props.clientReverseTags || []) {
  65. if (t) tags.add(t);
  66. }
  67. return [...tags];
  68. });
  69. // === Modal state ====================================================
  70. const modalOpen = ref(false);
  71. const editingBalancer = ref(null);
  72. const editingIndex = ref(null);
  73. const otherTags = ref([]);
  74. function tagPool(excludeIdx) {
  75. return rows.value.filter((b) => b.key !== excludeIdx).map((b) => b.tag).filter(Boolean);
  76. }
  77. function openAdd() {
  78. editingBalancer.value = null;
  79. editingIndex.value = null;
  80. otherTags.value = rows.value.map((b) => b.tag).filter(Boolean);
  81. modalOpen.value = true;
  82. }
  83. function openEdit(idx) {
  84. editingBalancer.value = rows.value[idx];
  85. editingIndex.value = idx;
  86. otherTags.value = tagPool(idx);
  87. modalOpen.value = true;
  88. }
  89. function ensureBalancersArray() {
  90. if (!props.templateSettings.routing) return null;
  91. if (!Array.isArray(props.templateSettings.routing.balancers)) {
  92. props.templateSettings.routing.balancers = [];
  93. }
  94. return props.templateSettings.routing.balancers;
  95. }
  96. // Keep observatory / burstObservatory in sync with the configured
  97. // balancers. leastPing balancers feed Observatory's subjectSelector;
  98. // leastLoad balancers feed BurstObservatory's. When the matching
  99. // strategy disappears we drop the observatory entirely so the rendered
  100. // xray config stays minimal.
  101. function collectSelectors(list) {
  102. const out = new Set();
  103. list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
  104. return [...out];
  105. }
  106. function syncObservatories() {
  107. const t = props.templateSettings;
  108. if (!t) return;
  109. const balancers = t.routing?.balancers || [];
  110. const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
  111. if (leastPings.length > 0) {
  112. if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
  113. t.observatory.subjectSelector = collectSelectors(leastPings);
  114. } else {
  115. delete t.observatory;
  116. }
  117. const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
  118. if (leastLoads.length > 0) {
  119. if (!t.burstObservatory) {
  120. t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
  121. }
  122. t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
  123. } else {
  124. delete t.burstObservatory;
  125. }
  126. }
  127. function buildWireBalancer(form) {
  128. const out = {
  129. tag: form.tag,
  130. selector: [...form.selector],
  131. fallbackTag: form.fallbackTag,
  132. };
  133. if (form.strategy && form.strategy !== 'random') {
  134. out.strategy = { type: form.strategy };
  135. }
  136. return out;
  137. }
  138. function onConfirm(form) {
  139. const arr = ensureBalancersArray();
  140. if (!arr) return;
  141. const wire = buildWireBalancer(form);
  142. if (editingIndex.value == null) {
  143. arr.push(wire);
  144. } else {
  145. const oldTag = arr[editingIndex.value]?.tag;
  146. arr[editingIndex.value] = wire;
  147. // Preserve the legacy behaviour: when a balancer's tag is renamed,
  148. // chase the rename across routing rules so existing references
  149. // don't dangle.
  150. if (oldTag && oldTag !== wire.tag) {
  151. const rules = props.templateSettings.routing.rules || [];
  152. for (const rule of rules) {
  153. if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
  154. }
  155. }
  156. }
  157. syncObservatories();
  158. modalOpen.value = false;
  159. }
  160. function confirmDelete(idx) {
  161. Modal.confirm({
  162. title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
  163. okText: t('delete'),
  164. okType: 'danger',
  165. cancelText: t('cancel'),
  166. // Wrap in a block so we discard splice's return value — AD-Vue
  167. // 4 leaves the modal open if onOk returns a truthy non-thenable
  168. // (it expects a Promise to await), and splice() returns the array
  169. // of removed items.
  170. onOk: () => {
  171. props.templateSettings.routing.balancers.splice(idx, 1);
  172. syncObservatories();
  173. },
  174. });
  175. }
  176. const columns = computed(() => [
  177. { title: '#', key: 'action', align: 'center', width: 80 },
  178. { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
  179. { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
  180. { title: 'Selector', key: 'selector', align: 'center' },
  181. { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
  182. ]);
  183. // === Observatory / BurstObservatory inline editor ====================
  184. // The legacy panel surfaced both top-level observatory blocks here as a
  185. // raw JSON editor so admins could tune probeURL / interval / sampling
  186. // without having to drop into the full xray template tab. We keep that
  187. // affordance but only render it when the matching observatory exists —
  188. // which is itself driven by syncObservatories() above.
  189. const hasObservatory = computed(() => !!props.templateSettings?.observatory);
  190. const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
  191. const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
  192. const obsView = ref('observatory');
  193. // Keep the radio selection valid as observatories appear/disappear —
  194. // e.g. deleting the last leastPing balancer should flip the editor to
  195. // the burstObservatory pane instead of leaving it pointing at the
  196. // (now-removed) observatory key.
  197. watch(showObsEditor, () => {
  198. if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
  199. obsView.value = 'burstObservatory';
  200. } else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
  201. obsView.value = 'observatory';
  202. }
  203. }, { immediate: true });
  204. const obsText = computed({
  205. get: () => {
  206. const t = props.templateSettings;
  207. if (!t) return '';
  208. const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
  209. return src ? JSON.stringify(src, null, 2) : '';
  210. },
  211. set: (next) => {
  212. let parsed;
  213. try { parsed = JSON.parse(next); } catch (_e) { return; }
  214. if (!props.templateSettings) return;
  215. if (obsView.value === 'observatory') {
  216. props.templateSettings.observatory = parsed;
  217. } else {
  218. props.templateSettings.burstObservatory = parsed;
  219. }
  220. },
  221. });
  222. </script>
  223. <template>
  224. <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
  225. <a-empty v-if="rows.length === 0" :description="t('emptyBalancersDesc')">
  226. <a-button type="primary" @click="openAdd">
  227. <template #icon>
  228. <PlusOutlined />
  229. </template>
  230. {{ t('pages.xray.Balancers') }}
  231. </a-button>
  232. </a-empty>
  233. <template v-else>
  234. <a-button type="primary" @click="openAdd">
  235. <template #icon>
  236. <PlusOutlined />
  237. </template>
  238. {{ t('pages.xray.Balancers') }}
  239. </a-button>
  240. <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
  241. <template #bodyCell="{ column, record, index }">
  242. <template v-if="column.key === 'action'">
  243. <span class="row-index">{{ index + 1 }}</span>
  244. <a-dropdown :trigger="['click']">
  245. <a-button shape="circle" size="small" class="action-btn">
  246. <MoreOutlined />
  247. </a-button>
  248. <template #overlay>
  249. <a-menu>
  250. <a-menu-item @click="openEdit(index)">
  251. <EditOutlined /> {{ t('edit') }}
  252. </a-menu-item>
  253. <a-menu-item class="danger" @click="confirmDelete(index)">
  254. <DeleteOutlined /> {{ t('delete') }}
  255. </a-menu-item>
  256. </a-menu>
  257. </template>
  258. </a-dropdown>
  259. </template>
  260. <template v-else-if="column.key === 'strategy'">
  261. <a-tag :color="record.strategy === 'random' ? 'purple' : 'green'">
  262. {{ STRATEGY_LABELS[record.strategy] || record.strategy }}
  263. </a-tag>
  264. </template>
  265. <template v-else-if="column.key === 'selector'">
  266. <a-tag v-for="sel in record.selector" :key="sel" class="info-large-tag">{{ sel }}</a-tag>
  267. </template>
  268. </template>
  269. </a-table>
  270. <template v-if="showObsEditor">
  271. <a-divider :style="{ margin: '8px 0' }" />
  272. <a-radio-group v-model:value="obsView" button-style="solid" size="small">
  273. <a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
  274. <a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
  275. </a-radio-group>
  276. <a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
  277. class="json-editor" />
  278. </template>
  279. </template>
  280. <BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
  281. :other-tags="otherTags" @confirm="onConfirm" />
  282. </a-space>
  283. </template>
  284. <style scoped>
  285. .row-index {
  286. font-weight: 500;
  287. opacity: 0.7;
  288. margin-right: 6px;
  289. }
  290. .action-btn {
  291. vertical-align: middle;
  292. }
  293. .danger {
  294. color: #ff4d4f;
  295. }
  296. .json-editor {
  297. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  298. font-size: 12px;
  299. margin-top: 8px;
  300. }
  301. </style>