| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- <script setup>
- import { computed, ref, watch } from 'vue';
- import { useI18n } from 'vue-i18n';
- import {
- PlusOutlined,
- MoreOutlined,
- EditOutlined,
- DeleteOutlined,
- } from '@ant-design/icons-vue';
- import { Modal } from 'ant-design-vue';
- import BalancerFormModal from './BalancerFormModal.vue';
- const { t } = useI18n();
- // Balancers tab — list + add/edit/delete over
- // templateSettings.routing.balancers. The legacy panel kept the wire
- // shape's `strategy: { type: 'random' }` nesting only when non-default;
- // we follow the same convention on submit.
- const props = defineProps({
- templateSettings: { type: Object, default: null },
- clientReverseTags: { type: Array, default: () => [] },
- });
- const STRATEGY_LABELS = {
- random: 'Random',
- roundRobin: 'Round robin',
- leastLoad: 'Least load',
- 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) => ({
- key: idx,
- tag: b.tag || '',
- strategy: b.strategy?.type || 'random',
- selector: b.selector || [],
- fallbackTag: b.fallbackTag || '',
- }));
- });
- const outboundTags = computed(() => {
- const tags = new Set();
- for (const o of props.templateSettings?.outbounds || []) {
- if (o.tag) tags.add(o.tag);
- }
- for (const t of props.clientReverseTags || []) {
- if (t) tags.add(t);
- }
- return [...tags];
- });
- // === Modal state ====================================================
- const modalOpen = ref(false);
- const editingBalancer = ref(null);
- const editingIndex = ref(null);
- const otherTags = ref([]);
- function tagPool(excludeIdx) {
- return rows.value.filter((b) => b.key !== excludeIdx).map((b) => b.tag).filter(Boolean);
- }
- function openAdd() {
- editingBalancer.value = null;
- editingIndex.value = null;
- otherTags.value = rows.value.map((b) => b.tag).filter(Boolean);
- modalOpen.value = true;
- }
- function openEdit(idx) {
- editingBalancer.value = rows.value[idx];
- editingIndex.value = idx;
- otherTags.value = tagPool(idx);
- modalOpen.value = true;
- }
- function ensureBalancersArray() {
- if (!props.templateSettings.routing) return null;
- if (!Array.isArray(props.templateSettings.routing.balancers)) {
- 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) {
- const out = {
- tag: form.tag,
- selector: [...form.selector],
- fallbackTag: form.fallbackTag,
- };
- if (form.strategy && form.strategy !== 'random') {
- out.strategy = { type: form.strategy };
- }
- return out;
- }
- function onConfirm(form) {
- const arr = ensureBalancersArray();
- if (!arr) return;
- const wire = buildWireBalancer(form);
- if (editingIndex.value == null) {
- arr.push(wire);
- } else {
- const oldTag = arr[editingIndex.value]?.tag;
- arr[editingIndex.value] = wire;
- // Preserve the legacy behaviour: when a balancer's tag is renamed,
- // chase the rename across routing rules so existing references
- // don't dangle.
- if (oldTag && oldTag !== wire.tag) {
- const rules = props.templateSettings.routing.rules || [];
- for (const rule of rules) {
- if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
- }
- }
- }
- syncObservatories();
- modalOpen.value = false;
- }
- function confirmDelete(idx) {
- Modal.confirm({
- title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
- okText: t('delete'),
- okType: 'danger',
- cancelText: t('cancel'),
- // Wrap in a block so we discard splice's return value — AD-Vue
- // 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);
- syncObservatories();
- },
- });
- }
- const columns = computed(() => [
- { title: '#', key: 'action', align: 'center', width: 80 },
- { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
- { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
- { 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>
- <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
- <a-empty v-if="rows.length === 0" :description="t('emptyBalancersDesc')">
- <a-button type="primary" @click="openAdd">
- <template #icon>
- <PlusOutlined />
- </template>
- {{ t('pages.xray.Balancers') }}
- </a-button>
- </a-empty>
- <template v-else>
- <a-button type="primary" @click="openAdd">
- <template #icon>
- <PlusOutlined />
- </template>
- {{ t('pages.xray.Balancers') }}
- </a-button>
- <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
- <template #bodyCell="{ column, record, index }">
- <template v-if="column.key === 'action'">
- <span class="row-index">{{ index + 1 }}</span>
- <a-dropdown :trigger="['click']">
- <a-button shape="circle" size="small" class="action-btn">
- <MoreOutlined />
- </a-button>
- <template #overlay>
- <a-menu>
- <a-menu-item @click="openEdit(index)">
- <EditOutlined /> {{ t('edit') }}
- </a-menu-item>
- <a-menu-item class="danger" @click="confirmDelete(index)">
- <DeleteOutlined /> {{ t('delete') }}
- </a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- </template>
- <template v-else-if="column.key === 'strategy'">
- <a-tag :color="record.strategy === 'random' ? 'purple' : 'green'">
- {{ STRATEGY_LABELS[record.strategy] || record.strategy }}
- </a-tag>
- </template>
- <template v-else-if="column.key === 'selector'">
- <a-tag v-for="sel in record.selector" :key="sel" class="info-large-tag">{{ sel }}</a-tag>
- </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"
- :other-tags="otherTags" @confirm="onConfirm" />
- </a-space>
- </template>
- <style scoped>
- .row-index {
- font-weight: 500;
- opacity: 0.7;
- margin-right: 6px;
- }
- .action-btn {
- vertical-align: middle;
- }
- .danger {
- color: #ff4d4f;
- }
- .json-editor {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 12px;
- margin-top: 8px;
- }
- </style>
|