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:
MHSanaei 2026-05-08 14:30:48 +02:00
parent 3f16b661ac
commit b69cc7a18e
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 431 additions and 2 deletions

View 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>

View 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>

View 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>

View file

@ -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">