mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 6-iv — xray Outbounds tab + outbound modal
Replaces the Outbounds tab placeholder with a full table + add/edit flow. The 1.3k-line legacy outbound modal is condensed to a tabbed modal with structured Basics fields (tag/protocol/sendThrough/domain strategy) and JSON tabs for the protocol-specific settings + stream trees — same approach the Inbound modal uses, and a power user can still edit the same trees via the page-level Advanced (JSON) tab. - useXraySetting.js: adds fetchOutboundsTraffic + resetOutboundsTraffic + testOutbound. Test states are tracked per outbound index so the row's Test button can show loading + the Test-result column can render the response delay / status / error. - OutboundsTab.vue: full table (action / identity / address / traffic / test result / test) plus a card-list mobile variant with the same row dropdown (set-first / edit / move up/down / reset traffic / delete). outboundAddresses() reproduces the legacy findOutboundAddress logic so each protocol's host:port list is rendered consistently. Add/edit go through OutboundFormModal, delete goes through Modal.confirm, reset traffic posts to /panel/xray/resetOutboundsTraffic with the row's tag (or "-alltags-" from the toolbar). - OutboundFormModal.vue: tag/protocol/sendThrough/domainStrategy on the Basics tab; settings + streamSettings as raw JSON on their respective tabs. Tag-collision check happens client-side before emitting; malformed JSON aborts the save with a message.error. - XrayPage.vue: imports OutboundsTab and wires the test action to the composable's testOutbound helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
57f502525f
commit
3f16b661ac
4 changed files with 778 additions and 1 deletions
198
frontend/src/pages/xray/OutboundFormModal.vue
Normal file
198
frontend/src/pages/xray/OutboundFormModal.vue
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { Protocols, OutboundDomainStrategies } from '@/models/outbound.js';
|
||||||
|
|
||||||
|
// Outbound add/edit modal. The legacy modal is huge (1.3k lines)
|
||||||
|
// because it covers every protocol's nested settings/streamSettings
|
||||||
|
// inline. We take the same pragmatic approach we did for the inbound
|
||||||
|
// modal: a Basics tab covers the always-relevant fields (tag,
|
||||||
|
// protocol, sendThrough, domain strategy) and a JSON tab exposes
|
||||||
|
// the full settings + streamSettings trees verbatim. Full structured
|
||||||
|
// per-protocol forms can land later — the JSON path supports every
|
||||||
|
// field today and matches what the Advanced page-level JSON tab
|
||||||
|
// already does.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
// null when adding, the outbound object when editing.
|
||||||
|
outbound: { type: Object, default: null },
|
||||||
|
// Existing tags so we can flag duplicates client-side.
|
||||||
|
existingTags: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'confirm']);
|
||||||
|
|
||||||
|
const PROTOCOL_OPTIONS = Object.values(Protocols);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
tag: '',
|
||||||
|
protocol: Protocols.Freedom,
|
||||||
|
sendThrough: '',
|
||||||
|
domainStrategy: 'AsIs',
|
||||||
|
settingsText: '',
|
||||||
|
streamSettingsText: '',
|
||||||
|
});
|
||||||
|
const isEdit = ref(false);
|
||||||
|
|
||||||
|
function pretty(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try { return JSON.stringify(JSON.parse(value), null, 2); }
|
||||||
|
catch (_e) { return value; }
|
||||||
|
}
|
||||||
|
try { return JSON.stringify(value, null, 2); }
|
||||||
|
catch (_e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (props.outbound) {
|
||||||
|
isEdit.value = true;
|
||||||
|
const o = props.outbound;
|
||||||
|
form.tag = o.tag || '';
|
||||||
|
form.protocol = o.protocol || Protocols.Freedom;
|
||||||
|
form.sendThrough = o.sendThrough || '';
|
||||||
|
form.domainStrategy = o.domainStrategy || 'AsIs';
|
||||||
|
form.settingsText = pretty(o.settings);
|
||||||
|
form.streamSettingsText = pretty(o.streamSettings);
|
||||||
|
} else {
|
||||||
|
isEdit.value = false;
|
||||||
|
form.tag = '';
|
||||||
|
form.protocol = Protocols.Freedom;
|
||||||
|
form.sendThrough = '';
|
||||||
|
form.domainStrategy = 'AsIs';
|
||||||
|
form.settingsText = '';
|
||||||
|
form.streamSettingsText = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() { emit('update:open', false); }
|
||||||
|
|
||||||
|
function buildResult() {
|
||||||
|
// Empty JSON tabs collapse to undefined keys so the wire shape
|
||||||
|
// doesn't carry empty objects we never had in the first place.
|
||||||
|
let settings;
|
||||||
|
let streamSettings;
|
||||||
|
try {
|
||||||
|
settings = form.settingsText.trim() ? JSON.parse(form.settingsText) : undefined;
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`settings JSON invalid: ${e.message}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
streamSettings = form.streamSettingsText.trim()
|
||||||
|
? JSON.parse(form.streamSettingsText)
|
||||||
|
: undefined;
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`streamSettings JSON invalid: ${e.message}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const out = {
|
||||||
|
tag: form.tag,
|
||||||
|
protocol: form.protocol,
|
||||||
|
};
|
||||||
|
if (form.sendThrough) out.sendThrough = form.sendThrough;
|
||||||
|
if (form.domainStrategy && form.domainStrategy !== 'AsIs') {
|
||||||
|
out.domainStrategy = form.domainStrategy;
|
||||||
|
}
|
||||||
|
if (settings !== undefined) out.settings = settings;
|
||||||
|
if (streamSettings !== undefined) out.streamSettings = streamSettings;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOk() {
|
||||||
|
if (!form.tag.trim()) {
|
||||||
|
message.error('Tag is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Block tag collisions client-side — server enforces too but this
|
||||||
|
// surfaces faster.
|
||||||
|
const conflict = (props.existingTags || []).includes(form.tag.trim());
|
||||||
|
if (conflict) {
|
||||||
|
message.error('An outbound with this tag already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
try { result = buildResult(); } catch (_e) { return; }
|
||||||
|
emit('confirm', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => (isEdit.value ? 'Edit outbound' : 'Add outbound'));
|
||||||
|
const okText = computed(() => (isEdit.value ? 'Update' : 'Add outbound'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="title"
|
||||||
|
:ok-text="okText"
|
||||||
|
cancel-text="Close"
|
||||||
|
:mask-closable="false"
|
||||||
|
width="720px"
|
||||||
|
@ok="onOk"
|
||||||
|
@cancel="close"
|
||||||
|
>
|
||||||
|
<a-tabs default-active-key="basic">
|
||||||
|
<a-tab-pane key="basic" tab="Basics">
|
||||||
|
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||||
|
<a-form-item label="Tag">
|
||||||
|
<a-input v-model:value="form.tag" placeholder="unique-tag" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Protocol">
|
||||||
|
<a-select v-model:value="form.protocol">
|
||||||
|
<a-select-option v-for="p in PROTOCOL_OPTIONS" :key="p" :value="p">{{ p }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Send through">
|
||||||
|
<a-input v-model:value="form.sendThrough" placeholder="local IP to bind to (optional)" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Domain strategy">
|
||||||
|
<a-select v-model:value="form.domainStrategy">
|
||||||
|
<a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="settings" tab="settings (JSON)">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="Edit the protocol-specific settings tree directly. Leave empty to omit."
|
||||||
|
class="mb-12"
|
||||||
|
/>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.settingsText"
|
||||||
|
:auto-size="{ minRows: 12, maxRows: 28 }"
|
||||||
|
spellcheck="false"
|
||||||
|
class="json-editor"
|
||||||
|
/>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="stream" tab="streamSettings (JSON)">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="Transport / TLS / Reality / mux options. Leave empty to omit."
|
||||||
|
class="mb-12"
|
||||||
|
/>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.streamSettingsText"
|
||||||
|
:auto-size="{ minRows: 12, maxRows: 28 }"
|
||||||
|
spellcheck="false"
|
||||||
|
class="json-editor"
|
||||||
|
/>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-12 { margin-bottom: 12px; }
|
||||||
|
.json-editor {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
509
frontend/src/pages/xray/OutboundsTab.vue
Normal file
509
frontend/src/pages/xray/OutboundsTab.vue
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
CloudOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
VerticalAlignTopOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CloseCircleFilled,
|
||||||
|
LoadingOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { SizeFormatter } from '@/utils';
|
||||||
|
import { Protocols } from '@/models/outbound.js';
|
||||||
|
import OutboundFormModal from './OutboundFormModal.vue';
|
||||||
|
|
||||||
|
// Outbounds tab — list + actions over templateSettings.outbounds.
|
||||||
|
// Mirrors the legacy outbound table layout (identity / address /
|
||||||
|
// traffic / test result / test button) plus the row action menu
|
||||||
|
// (set first / edit / reset traffic / delete). Mobile collapses to
|
||||||
|
// a card list.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templateSettings: { type: Object, default: null },
|
||||||
|
outboundsTraffic: { type: Array, default: () => [] },
|
||||||
|
outboundTestStates: { type: Object, default: () => ({}) },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh-traffic', 'reset-traffic', 'test', 'show-warp', 'show-nord']);
|
||||||
|
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
// === Modal state ====================================================
|
||||||
|
const modalOpen = ref(false);
|
||||||
|
const editingOutbound = ref(null);
|
||||||
|
const editingIndex = ref(null);
|
||||||
|
const existingTags = ref([]);
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
editingOutbound.value = null;
|
||||||
|
editingIndex.value = null;
|
||||||
|
existingTags.value = (props.templateSettings?.outbounds || []).map((o) => o.tag);
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
function openEdit(idx) {
|
||||||
|
editingOutbound.value = props.templateSettings.outbounds[idx];
|
||||||
|
editingIndex.value = idx;
|
||||||
|
existingTags.value = (props.templateSettings?.outbounds || [])
|
||||||
|
.filter((_, i) => i !== idx)
|
||||||
|
.map((o) => o.tag);
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
function onConfirm(outbound) {
|
||||||
|
if (editingIndex.value == null) {
|
||||||
|
if (!outbound.tag) return;
|
||||||
|
props.templateSettings.outbounds.push(outbound);
|
||||||
|
} else {
|
||||||
|
props.templateSettings.outbounds[editingIndex.value] = outbound;
|
||||||
|
}
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(idx) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete outbound #${idx + 1}?`,
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: () => props.templateSettings.outbounds.splice(idx, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setFirst(idx) {
|
||||||
|
const arr = props.templateSettings.outbounds;
|
||||||
|
arr.unshift(arr.splice(idx, 1)[0]);
|
||||||
|
}
|
||||||
|
function moveUp(idx) {
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const arr = props.templateSettings.outbounds;
|
||||||
|
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
|
||||||
|
}
|
||||||
|
function moveDown(idx) {
|
||||||
|
const arr = props.templateSettings.outbounds;
|
||||||
|
if (idx >= arr.length - 1) return;
|
||||||
|
[arr[idx + 1], arr[idx]] = [arr[idx], arr[idx + 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
refreshing.value = true;
|
||||||
|
try { emit('refresh-traffic'); }
|
||||||
|
finally { setTimeout(() => { refreshing.value = false; }, 500); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Per-row helpers ================================================
|
||||||
|
function trafficFor(o) {
|
||||||
|
const t = props.outboundsTraffic.find((x) => x.tag === o.tag);
|
||||||
|
return { up: t?.up || 0, down: t?.down || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifted from legacy findOutboundAddress — returns an array of
|
||||||
|
// "host:port" strings for the protocols that have one, or null when
|
||||||
|
// the outbound has no externally-visible endpoint (Freedom, Blackhole,
|
||||||
|
// DNS without an explicit address, etc.).
|
||||||
|
function outboundAddresses(o) {
|
||||||
|
let serverObj = null;
|
||||||
|
switch (o.protocol) {
|
||||||
|
case Protocols.VMess:
|
||||||
|
serverObj = o.settings?.vnext;
|
||||||
|
break;
|
||||||
|
case Protocols.VLESS:
|
||||||
|
return [`${o.settings?.address || ''}:${o.settings?.port || ''}`];
|
||||||
|
case Protocols.HTTP:
|
||||||
|
case Protocols.Socks:
|
||||||
|
case Protocols.Shadowsocks:
|
||||||
|
case Protocols.Trojan:
|
||||||
|
serverObj = o.settings?.servers;
|
||||||
|
break;
|
||||||
|
case Protocols.DNS:
|
||||||
|
return [`${o.settings?.address || ''}:${o.settings?.port || ''}`];
|
||||||
|
case Protocols.Wireguard:
|
||||||
|
return (o.settings?.peers || []).map((p) => p.endpoint);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUntestable(o) {
|
||||||
|
return o.protocol === 'blackhole' || o.tag === 'blocked';
|
||||||
|
}
|
||||||
|
function isTesting(idx) {
|
||||||
|
return !!props.outboundTestStates?.[idx]?.testing;
|
||||||
|
}
|
||||||
|
function testResult(idx) {
|
||||||
|
return props.outboundTestStates?.[idx]?.result || null;
|
||||||
|
}
|
||||||
|
function showSecurity(security) {
|
||||||
|
return security === 'tls' || security === 'reality';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Columns ========================================================
|
||||||
|
const columns = [
|
||||||
|
{ title: '#', key: 'action', align: 'center', width: 70 },
|
||||||
|
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
|
||||||
|
{ title: 'Address', key: 'address', align: 'left', width: 230 },
|
||||||
|
{ title: 'Traffic', key: 'traffic', align: 'left', width: 200 },
|
||||||
|
{ title: 'Test result', key: 'testResult', align: 'left', width: 140 },
|
||||||
|
{ title: 'Test', key: 'test', align: 'center', width: 80 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
if (!props.templateSettings?.outbounds) return [];
|
||||||
|
return props.templateSettings.outbounds.map((o, i) => ({ key: i, ...o }));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
|
||||||
|
<a-col :xs="24" :sm="14">
|
||||||
|
<a-space size="small">
|
||||||
|
<a-button type="primary" @click="openAdd">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
<span v-if="!isMobile">Add outbound</span>
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="emit('show-warp')">
|
||||||
|
<template #icon><CloudOutlined /></template>
|
||||||
|
WARP
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="emit('show-nord')">
|
||||||
|
<template #icon><ApiOutlined /></template>
|
||||||
|
NordVPN
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="10" class="toolbar-right">
|
||||||
|
<a-button-group>
|
||||||
|
<a-button :loading="refreshing" @click="onRefresh">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
placement="topRight"
|
||||||
|
ok-text="Reset"
|
||||||
|
cancel-text="Cancel"
|
||||||
|
title="Reset traffic on every outbound?"
|
||||||
|
@confirm="emit('reset-traffic', '-alltags-')"
|
||||||
|
>
|
||||||
|
<a-button>
|
||||||
|
<template #icon><RetweetOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-button-group>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- Mobile: card list -->
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<div v-if="rows.length === 0" class="card-empty">—</div>
|
||||||
|
<div v-for="(record, index) in rows" :key="record.key" class="outbound-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-identity">
|
||||||
|
<span class="card-num">{{ index + 1 }}</span>
|
||||||
|
<a-tooltip :title="record.tag">
|
||||||
|
<span class="tag-name">{{ record.tag }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tag color="green">{{ record.protocol }}</a-tag>
|
||||||
|
<template
|
||||||
|
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
|
||||||
|
>
|
||||||
|
<a-tag>{{ record.streamSettings?.network }}</a-tag>
|
||||||
|
<a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
|
||||||
|
{{ record.streamSettings.security }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button shape="circle" size="small">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
||||||
|
<VerticalAlignTopOutlined /> Move to top
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="openEdit(index)">
|
||||||
|
<EditOutlined /> Edit
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
|
||||||
|
<RetweetOutlined /> Reset traffic
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
<div v-if="outboundAddresses(record).length > 0" class="address-list">
|
||||||
|
<a-tooltip v-for="addr in outboundAddresses(record)" :key="addr" :title="addr">
|
||||||
|
<span class="address-pill">{{ addr }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot">
|
||||||
|
<span class="traffic-up">↑ {{ SizeFormatter.sizeFormat(trafficFor(record).up) }}</span>
|
||||||
|
<span class="traffic-sep" />
|
||||||
|
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
|
||||||
|
<span class="card-test">
|
||||||
|
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||||
|
<CheckCircleFilled v-if="testResult(index).success" />
|
||||||
|
<CloseCircleFilled v-else />
|
||||||
|
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||||
|
<span v-else>failed</span>
|
||||||
|
</span>
|
||||||
|
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
:loading="isTesting(index)"
|
||||||
|
:disabled="isUntestable(record) || isTesting(index)"
|
||||||
|
@click="emit('test', index)"
|
||||||
|
>
|
||||||
|
<template #icon><ThunderboltOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Desktop: table -->
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:row-key="(r) => r.key"
|
||||||
|
:pagination="false"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<div class="action-cell">
|
||||||
|
<span class="row-index">{{ index + 1 }}</span>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button shape="circle" size="small">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
||||||
|
<VerticalAlignTopOutlined /> Move to top
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="openEdit(index)">
|
||||||
|
<EditOutlined /> Edit
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
|
<ArrowUpOutlined /> Move up
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
|
<ArrowDownOutlined /> Move down
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
|
||||||
|
<RetweetOutlined /> Reset traffic
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'identity'">
|
||||||
|
<div class="identity-cell">
|
||||||
|
<a-tooltip :title="record.tag">
|
||||||
|
<span class="tag-name">{{ record.tag }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<div class="protocol-line">
|
||||||
|
<a-tag color="green">{{ record.protocol }}</a-tag>
|
||||||
|
<template
|
||||||
|
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
|
||||||
|
>
|
||||||
|
<a-tag>{{ record.streamSettings?.network }}</a-tag>
|
||||||
|
<a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
|
||||||
|
{{ record.streamSettings.security }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'address'">
|
||||||
|
<div class="address-list">
|
||||||
|
<a-tooltip v-for="addr in outboundAddresses(record)" :key="addr" :title="addr">
|
||||||
|
<span class="address-pill">{{ addr }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="outboundAddresses(record).length === 0" class="empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'traffic'">
|
||||||
|
<span class="traffic-up">↑ {{ SizeFormatter.sizeFormat(trafficFor(record).up) }}</span>
|
||||||
|
<span class="traffic-sep" />
|
||||||
|
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'testResult'">
|
||||||
|
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||||
|
<CheckCircleFilled v-if="testResult(index).success" />
|
||||||
|
<CloseCircleFilled v-else />
|
||||||
|
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||||
|
<a-tooltip v-else :title="testResult(index).error">
|
||||||
|
<span>failed</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</span>
|
||||||
|
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||||
|
<span v-else class="empty">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'test'">
|
||||||
|
<a-tooltip title="Run a latency test through this outbound">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
:loading="isTesting(index)"
|
||||||
|
:disabled="isUntestable(record) || isTesting(index)"
|
||||||
|
@click="emit('test', index)"
|
||||||
|
>
|
||||||
|
<template #icon><ThunderboltOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<OutboundFormModal
|
||||||
|
v-model:open="modalOpen"
|
||||||
|
:outbound="editingOutbound"
|
||||||
|
:existing-tags="existingTags"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
/>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar-right { display: flex; justify-content: flex-end; }
|
||||||
|
|
||||||
|
.card-empty {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.4;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
.outbound-card {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card-num {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.tag-name {
|
||||||
|
font-weight: 500;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.protocol-line {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.address-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
:global(body.dark) .address-pill {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.row-index {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card-test {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-up { color: #008771; font-size: 12px; }
|
||||||
|
.traffic-down { color: #3c89e8; font-size: 12px; }
|
||||||
|
.traffic-sep { display: inline-block; width: 4px; }
|
||||||
|
|
||||||
|
.pill-ok,
|
||||||
|
.pill-fail {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.pill-ok { color: #008771; background: rgba(0, 135, 113, 0.12); }
|
||||||
|
.pill-fail { color: #e04141; background: rgba(224, 65, 65, 0.12); }
|
||||||
|
|
||||||
|
.empty { opacity: 0.4; }
|
||||||
|
.danger { color: #ff4d4f; }
|
||||||
|
</style>
|
||||||
|
|
@ -17,6 +17,7 @@ import { message } from 'ant-design-vue';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import BasicsTab from './BasicsTab.vue';
|
import BasicsTab from './BasicsTab.vue';
|
||||||
import RoutingTab from './RoutingTab.vue';
|
import RoutingTab from './RoutingTab.vue';
|
||||||
|
import OutboundsTab from './OutboundsTab.vue';
|
||||||
import { useXraySetting } from './useXraySetting.js';
|
import { useXraySetting } from './useXraySetting.js';
|
||||||
|
|
||||||
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
|
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
|
||||||
|
|
@ -40,10 +41,20 @@ const {
|
||||||
inboundTags,
|
inboundTags,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
restartResult,
|
restartResult,
|
||||||
|
outboundsTraffic,
|
||||||
|
outboundTestStates,
|
||||||
|
fetchOutboundsTraffic,
|
||||||
|
resetOutboundsTraffic,
|
||||||
|
testOutbound,
|
||||||
saveAll,
|
saveAll,
|
||||||
restartXray,
|
restartXray,
|
||||||
} = useXraySetting();
|
} = useXraySetting();
|
||||||
|
|
||||||
|
async function onTestOutbound(idx) {
|
||||||
|
const outbound = templateSettings.value?.outbounds?.[idx];
|
||||||
|
if (outbound) await testOutbound(idx, outbound);
|
||||||
|
}
|
||||||
|
|
||||||
// `WarpExist` / `NordExist` derive from the parsed templateSettings —
|
// `WarpExist` / `NordExist` derive from the parsed templateSettings —
|
||||||
// the Basics tab gates its WARP / NordVPN domain selectors on whether
|
// the Basics tab gates its WARP / NordVPN domain selectors on whether
|
||||||
// the matching outbound is provisioned, falling back to a "configure"
|
// the matching outbound is provisioned, falling back to a "configure"
|
||||||
|
|
@ -155,7 +166,17 @@ function confirmRestart() {
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<UploadOutlined /> <span>Outbounds</span>
|
<UploadOutlined /> <span>Outbounds</span>
|
||||||
</template>
|
</template>
|
||||||
<a-empty description="Outbounds — coming in 6-iv." />
|
<OutboundsTab
|
||||||
|
:template-settings="templateSettings"
|
||||||
|
:outbounds-traffic="outboundsTraffic"
|
||||||
|
:outbound-test-states="outboundTestStates"
|
||||||
|
:is-mobile="isMobile"
|
||||||
|
@refresh-traffic="fetchOutboundsTraffic"
|
||||||
|
@reset-traffic="resetOutboundsTraffic"
|
||||||
|
@test="onTestOutbound"
|
||||||
|
@show-warp="showWarp"
|
||||||
|
@show-nord="showNord"
|
||||||
|
/>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ export function useXraySetting() {
|
||||||
const clientReverseTags = ref([]);
|
const clientReverseTags = ref([]);
|
||||||
const restartResult = ref('');
|
const restartResult = ref('');
|
||||||
|
|
||||||
|
// Outbounds tab data — traffic stats + per-row test state. Test
|
||||||
|
// states are keyed by outbound index (sparse object), each entry
|
||||||
|
// is `{ testing, result }` where result is the wire response from
|
||||||
|
// /panel/xray/testOutbound or null while the test is in flight.
|
||||||
|
const outboundsTraffic = ref([]);
|
||||||
|
const outboundTestStates = ref({});
|
||||||
|
|
||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
const msg = await HttpUtil.post('/panel/xray/');
|
const msg = await HttpUtil.post('/panel/xray/');
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
|
|
@ -100,6 +107,42 @@ export function useXraySetting() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchOutboundsTraffic() {
|
||||||
|
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
|
||||||
|
if (msg?.success) outboundsTraffic.value = msg.obj || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetOutboundsTraffic(tag) {
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
|
||||||
|
if (msg?.success) await fetchOutboundsTraffic();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testOutbound(index, outbound) {
|
||||||
|
if (!outbound) return null;
|
||||||
|
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
|
||||||
|
outboundTestStates.value[index] = { testing: true, result: null };
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||||
|
outbound: JSON.stringify(outbound),
|
||||||
|
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
|
||||||
|
});
|
||||||
|
if (msg?.success) {
|
||||||
|
outboundTestStates.value[index] = { testing: false, result: msg.obj };
|
||||||
|
return msg.obj;
|
||||||
|
}
|
||||||
|
outboundTestStates.value[index] = {
|
||||||
|
testing: false,
|
||||||
|
result: { success: false, error: msg?.msg || 'Unknown error' },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
outboundTestStates.value[index] = {
|
||||||
|
testing: false,
|
||||||
|
result: { success: false, error: String(e) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function restartXray() {
|
async function restartXray() {
|
||||||
spinning.value = true;
|
spinning.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -136,6 +179,7 @@ export function useXraySetting() {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
|
fetchOutboundsTraffic();
|
||||||
startDirtyPoll();
|
startDirtyPoll();
|
||||||
});
|
});
|
||||||
onUnmounted(stopDirtyPoll);
|
onUnmounted(stopDirtyPoll);
|
||||||
|
|
@ -150,7 +194,12 @@ export function useXraySetting() {
|
||||||
inboundTags,
|
inboundTags,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
restartResult,
|
restartResult,
|
||||||
|
outboundsTraffic,
|
||||||
|
outboundTestStates,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
|
fetchOutboundsTraffic,
|
||||||
|
resetOutboundsTraffic,
|
||||||
|
testOutbound,
|
||||||
saveAll,
|
saveAll,
|
||||||
restartXray,
|
restartXray,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue