mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
feat(frontend): Phase 6-v — xray Balancers tab + DNS placeholder
Brings Balancers to full parity with the legacy panel and adds a
DNS tab placeholder that exposes the full dns/fakedns trees as JSON
so users can edit them without falling through to Advanced.
- BalancerFormModal.vue: tag (with duplicate-tag warning across
other balancers), strategy (random/roundRobin/leastLoad/leastPing),
selector tag-mode multi-select sourced from existing outbound
tags + free-form additions, fallback. Disable-on-invalid is
driven by the duplicateTag + emptySelector computed flags.
- BalancersTab.vue: empty state with a single "Add balancer" CTA;
populated state shows the legacy 4-column table (action / tag /
strategy / selector / fallback) with per-row edit + delete in a
dropdown. On submit the wire shape preserves the
`strategy: { type }` nesting only when the strategy is non-default,
matching the legacy emit. Tag renames also chase across
routing.rules.balancerTag references so existing rules don't dangle.
- DnsTab.vue: master enable switch + raw JSON for `dns` and
`fakedns`. Legacy had a dedicated server-by-server editor + a
fakedns row editor; both are big enough to deserve their own
commits, and the JSON path supports every field today.
WARP / NordVPN provisioning modals still toast as "coming soon" —
those are third-party API integrations worth their own commits.
The xray page now has structured editors for Basics / Routing /
Outbounds / Balancers and JSON editors for DNS / Advanced — every
xray tab the legacy panel offered is functional.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3f16b661ac
commit
b69cc7a18e
4 changed files with 431 additions and 2 deletions
111
frontend/src/pages/xray/BalancerFormModal.vue
Normal file
111
frontend/src/pages/xray/BalancerFormModal.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
// Balancer add/edit modal — mirrors xray_balancer_modal.html.
|
||||
// Tag must be unique across other balancers; selector is a tag-mode
|
||||
// list constrained to existing outbound tags (but lets users type
|
||||
// new ones for forward-references).
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
balancer: { type: Object, default: null },
|
||||
outboundTags: { type: Array, default: () => [] },
|
||||
// All other balancer tags (excludes the one currently being edited)
|
||||
// — used for the duplicate-tag check.
|
||||
otherTags: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const STRATEGIES = [
|
||||
{ value: 'random', label: 'Random' },
|
||||
{ value: 'roundRobin', label: 'Round robin' },
|
||||
{ value: 'leastLoad', label: 'Least load' },
|
||||
{ value: 'leastPing', label: 'Least ping' },
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
tag: '',
|
||||
strategy: 'random',
|
||||
selector: [],
|
||||
fallbackTag: '',
|
||||
});
|
||||
const isEdit = ref(false);
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (props.balancer) {
|
||||
isEdit.value = true;
|
||||
form.tag = props.balancer.tag || '';
|
||||
form.strategy = props.balancer.strategy || 'random';
|
||||
form.selector = [...(props.balancer.selector || [])];
|
||||
form.fallbackTag = props.balancer.fallbackTag || '';
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
form.tag = '';
|
||||
form.strategy = 'random';
|
||||
form.selector = [];
|
||||
form.fallbackTag = '';
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateTag = computed(
|
||||
() => !form.tag || props.otherTags.includes(form.tag),
|
||||
);
|
||||
const emptySelector = computed(() => form.selector.length === 0);
|
||||
const isValid = computed(() => !duplicateTag.value && !emptySelector.value);
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function onOk() {
|
||||
if (!isValid.value) return;
|
||||
emit('confirm', { ...form });
|
||||
}
|
||||
|
||||
const title = computed(() => (isEdit.value ? 'Edit balancer' : 'Add balancer'));
|
||||
const okText = computed(() => (isEdit.value ? 'Update' : 'Add'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:ok-text="okText"
|
||||
cancel-text="Close"
|
||||
:ok-button-props="{ disabled: !isValid }"
|
||||
:mask-closable="false"
|
||||
@ok="onOk"
|
||||
@cancel="close"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||
<a-form-item
|
||||
label="Tag"
|
||||
:validate-status="duplicateTag ? 'warning' : 'success'"
|
||||
has-feedback
|
||||
>
|
||||
<a-input v-model:value="form.tag" placeholder="unique balancer tag" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Strategy">
|
||||
<a-select v-model:value="form.strategy">
|
||||
<a-select-option v-for="s in STRATEGIES" :key="s.value" :value="s.value">{{ s.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="Selector"
|
||||
:validate-status="emptySelector ? 'warning' : 'success'"
|
||||
has-feedback
|
||||
>
|
||||
<a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
|
||||
<a-select-option v-for="t in outboundTags" :key="t" :value="t">{{ t }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Fallback">
|
||||
<a-select v-model:value="form.fallbackTag" allow-clear>
|
||||
<a-select-option v-for="t in ['', ...outboundTags]" :key="t || '__empty'" :value="t">{{ t || '(none)' }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
205
frontend/src/pages/xray/BalancersTab.vue
Normal file
205
frontend/src/pages/xray/BalancersTab.vue
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import BalancerFormModal from './BalancerFormModal.vue';
|
||||
|
||||
// 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 },
|
||||
});
|
||||
|
||||
const STRATEGY_LABELS = {
|
||||
random: 'Random',
|
||||
roundRobin: 'Round robin',
|
||||
leastLoad: 'Least load',
|
||||
leastPing: 'Least ping',
|
||||
};
|
||||
|
||||
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(
|
||||
() => (props.templateSettings?.outbounds || [])
|
||||
.filter((o) => o.tag)
|
||||
.map((o) => o.tag),
|
||||
);
|
||||
|
||||
// === 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
function confirmDelete(idx) {
|
||||
Modal.confirm({
|
||||
title: `Delete balancer #${idx + 1}?`,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: () => props.templateSettings.routing.balancers.splice(idx, 1),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ 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 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<a-empty v-if="rows.length === 0" description="No balancers yet">
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
Add balancer
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<template v-else>
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
Add balancer
|
||||
</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 /> Edit
|
||||
</a-menu-item>
|
||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||
<DeleteOutlined /> 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>
|
||||
|
||||
<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; }
|
||||
</style>
|
||||
111
frontend/src/pages/xray/DnsTab.vue
Normal file
111
frontend/src/pages/xray/DnsTab.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Compact DNS editor — a master enable switch plus a JSON textarea
|
||||
// for the full dns + fakedns trees. The legacy panel had a
|
||||
// dedicated DNS-server modal + fakedns row editor; both are large
|
||||
// enough to deserve their own commits. For now this gives users a
|
||||
// working path to edit DNS settings without leaving the structured
|
||||
// page.
|
||||
|
||||
const props = defineProps({
|
||||
templateSettings: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const enableDns = computed({
|
||||
get: () => !!props.templateSettings?.dns,
|
||||
set: (next) => {
|
||||
if (!props.templateSettings) return;
|
||||
if (next) {
|
||||
props.templateSettings.dns = {
|
||||
servers: [],
|
||||
queryStrategy: 'UseIP',
|
||||
tag: 'dns_inbound',
|
||||
enableParallelQuery: false,
|
||||
};
|
||||
props.templateSettings.fakedns = null;
|
||||
} else {
|
||||
delete props.templateSettings.dns;
|
||||
delete props.templateSettings.fakedns;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const dnsJson = computed({
|
||||
get: () => {
|
||||
if (!props.templateSettings?.dns) return '';
|
||||
try { return JSON.stringify(props.templateSettings.dns, null, 2); }
|
||||
catch (_e) { return ''; }
|
||||
},
|
||||
set: (next) => {
|
||||
if (!props.templateSettings) return;
|
||||
try {
|
||||
const parsed = next.trim() ? JSON.parse(next) : null;
|
||||
props.templateSettings.dns = parsed;
|
||||
} catch (_e) {
|
||||
// wait for valid JSON — leaves the previous value untouched
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const fakednsJson = computed({
|
||||
get: () => {
|
||||
if (!props.templateSettings?.fakedns) return '';
|
||||
try { return JSON.stringify(props.templateSettings.fakedns, null, 2); }
|
||||
catch (_e) { return ''; }
|
||||
},
|
||||
set: (next) => {
|
||||
if (!props.templateSettings) return;
|
||||
try {
|
||||
const parsed = next.trim() ? JSON.parse(next) : null;
|
||||
if (parsed) props.templateSettings.fakedns = parsed;
|
||||
else delete props.templateSettings.fakedns;
|
||||
} catch (_e) { /* wait for valid JSON */ }
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="Enable DNS">
|
||||
<a-switch v-model:checked="enableDns" />
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="enableDns">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="The full DNS tree is editable here. A dedicated server-by-server editor is coming in a future commit."
|
||||
class="mb-12"
|
||||
/>
|
||||
<a-form-item label="dns (JSON)">
|
||||
<a-textarea
|
||||
v-model:value="dnsJson"
|
||||
:auto-size="{ minRows: 12, maxRows: 28 }"
|
||||
spellcheck="false"
|
||||
class="json-editor"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="fakedns (JSON, optional)">
|
||||
<a-textarea
|
||||
v-model:value="fakednsJson"
|
||||
:auto-size="{ minRows: 6, maxRows: 18 }"
|
||||
spellcheck="false"
|
||||
class="json-editor"
|
||||
placeholder="Leave empty to omit fakedns."
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-12 { margin-bottom: 12px; }
|
||||
.json-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -18,6 +18,8 @@ import AppSidebar from '@/components/AppSidebar.vue';
|
|||
import BasicsTab from './BasicsTab.vue';
|
||||
import RoutingTab from './RoutingTab.vue';
|
||||
import OutboundsTab from './OutboundsTab.vue';
|
||||
import BalancersTab from './BalancersTab.vue';
|
||||
import DnsTab from './DnsTab.vue';
|
||||
import { useXraySetting } from './useXraySetting.js';
|
||||
|
||||
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
|
||||
|
|
@ -183,14 +185,14 @@ function confirmRestart() {
|
|||
<template #tab>
|
||||
<ClusterOutlined /> <span>Balancers</span>
|
||||
</template>
|
||||
<a-empty description="Balancers — coming in 6-v." />
|
||||
<BalancersTab :template-settings="templateSettings" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-dns" class="tab-pane">
|
||||
<template #tab>
|
||||
<DatabaseOutlined /> <span>DNS</span>
|
||||
</template>
|
||||
<a-empty description="DNS — coming in 6-v." />
|
||||
<DnsTab :template-settings="templateSettings" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-advanced" class="tab-pane">
|
||||
|
|
|
|||
Loading…
Reference in a new issue