Files
3x-ui/web/html/xray.html
MHSanaei b2d32f588f new: vless reverse
legacy reverse removed
2026-05-05 21:00:03 +02:00

2275 lines
78 KiB
HTML

{{ template "page/head_start" .}}
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' xray-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
</a-alert>
</transition>
<transition name="list" appear>
<a-row v-if="!loadingStates.fetched">
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
<a-card hoverable>
<a-row :style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }">
<a-col :xs="24" :sm="10" :style="{ padding: '4px' }">
<a-space direction="horizontal">
<a-button type="primary" :disabled="saveBtnDisable" @click="updateXraySetting">
{{ i18n "pages.xray.save" }}
</a-button>
<a-button type="danger" :disabled="!saveBtnDisable" @click="restartXray">
{{ i18n "pages.xray.restart" }}
</a-button>
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
<span slot="title">{{ i18n
"pages.index.xrayErrorPopoverTitle" }}</span>
<template slot="content">
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line
]]</span>
</template>
<a-icon type="question-circle"></a-icon>
</a-popover>
</a-space>
</a-col>
<a-col :xs="24" :sm="14">
<template>
<div>
<a-back-top :target="() => document.getElementById('content-layout')"
visibility-height="200"></a-back-top>
<a-alert type="warning" :style="{ float: 'right', width: 'fit-content' }"
message='{{ i18n "pages.settings.infoDesc" }}' show-icon>
</a-alert>
</div>
</template>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col>
<a-tabs default-active-key="tpl-basic" @change="(activeKey) => { this.changePage(activeKey); }"
:class="themeSwitcher.currentTheme">
<a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="setting"></a-icon>
<span>{{ i18n "pages.xray.basicTemplate"}}</span>
</template>
{{ template "settings/xray/basics" . }}
</a-tab-pane>
<a-tab-pane key="tpl-routing" :style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="swap"></a-icon>
<span>{{ i18n "pages.xray.Routings"}}</span>
</template>
{{ template "settings/xray/routing" . }}
</a-tab-pane>
<a-tab-pane key="tpl-outbound" force-render="true">
<template #tab>
<a-icon type="upload"></a-icon>
<span>{{ i18n "pages.xray.Outbounds"}}</span>
</template>
{{ template "settings/xray/outbounds" . }}
</a-tab-pane>
<a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }" force-render="true">
<template #tab>
<a-icon type="cluster"></a-icon>
<span>{{ i18n "pages.xray.Balancers"}}</span>
</template>
{{ template "settings/xray/balancers" . }}
</a-tab-pane>
<a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }" force-render="true">
<template #tab>
<a-icon type="database"></a-icon>
<span>DNS</span>
</template>
{{ template "settings/xray/dns" . }}
</a-tab-pane>
<a-tab-pane key="tpl-advanced" force-render="true">
<template #tab>
<a-icon type="code"></a-icon>
<span>{{ i18n "pages.xray.advancedTemplate"}}</span>
</template>
{{ template "settings/xray/advanced" . }}
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
<script src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aTableSortable" .}}
{{template "component/aSettingListItem" .}}
{{template "modals/ruleModal" .}}
{{template "modals/outModal" .}}
{{template "modals/balancerModal" .}}
{{template "modals/dnsModal" .}}
{{template "modals/dnsPresetsModal" .}}
{{template "modals/fakednsModal" .}}
{{template "modals/warpModal" .}}
{{template "modals/nordModal" .}}
<script>
// Modernised rules layout — 6 cells (#, source, network, destination,
// inbound, target). Each criterion renders as a single self-labelled
// pill that shows the first value plus a "+N" remainder badge for the
// rest; the full list is surfaced via tooltip on hover. The destination
// column has no fixed width and absorbs leftover horizontal space so the
// table fits typical viewports without a horizontal scrollbar.
const rulesColumns = [
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.source"}}', align: 'left', width: 180, scopedSlots: { customRender: 'source' } },
{ title: '{{ i18n "pages.inbounds.network"}}', align: 'left', width: 180, scopedSlots: { customRender: 'network' } },
{ title: '{{ i18n "pages.xray.rules.dest"}}', align: 'left', scopedSlots: { customRender: 'destination' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', width: 180, scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 170, scopedSlots: { customRender: 'target' } },
];
// Mobile: 3-column table — #, Inbound, Outbound. Source / Network /
// Destination criteria are dropped to keep the table readable on
// narrow viewports. Users see the rule's identity (Inbound) and
// what it does (Outbound) at a glance; full criteria are accessible
// by tapping Edit in the actions menu.
// # column is wider than desktop (110 vs 70) to fit the touch-friendly
// drag handle (padding: 6px → ~28px) alongside the index and dropdown.
const rulesMobileColumns = [
{ title: '#', align: 'center', width: 110, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 140, scopedSlots: { customRender: 'target' } },
];
const outboundColumns = [
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
// Combined "Tag / Protocol" — saves a column. Tag stays on top, protocol +
// network + security pills sit underneath it. Width chosen so the three
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
// single line without wrapping.
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
];
const balancerColumns = [{
title: "#",
align: 'center',
width: 20,
scopedSlots: {
customRender: 'action'
}
},
{
title: '{{ i18n "pages.xray.balancer.tag"}}',
dataIndex: 'tag',
align: 'center',
width: 50
},
{
title: '{{ i18n "pages.xray.balancer.balancerStrategy"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'strategy'
}
},
{
title: '{{ i18n "pages.xray.balancer.balancerSelectors"}}',
align: 'center',
width: 100,
scopedSlots: {
customRender: 'selector'
}
},
];
const dnsColumns = [{
title: "#",
align: 'center',
width: 20,
scopedSlots: {
customRender: 'action'
}
},
{
title: '{{ i18n "pages.xray.outbound.address"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'address'
}
},
{
title: '{{ i18n "pages.xray.dns.domains"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'domain'
}
},
{
title: '{{ i18n "pages.xray.dns.expectIPs"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'expectIPs'
}
},
];
const fakednsColumns = [{
title: "#",
align: 'center',
width: 20,
scopedSlots: {
customRender: 'action'
}
},
{
title: '{{ i18n "pages.xray.fakedns.ipPool"}}',
dataIndex: 'ipPool',
align: 'center',
width: 50
},
{
title: '{{ i18n "pages.xray.fakedns.poolSize"}}',
dataIndex: 'poolSize',
align: 'center',
width: 50
},
];
const app = new Vue({
delimiters: ['[[', ']]'],
mixins: [MediaQueryMixin],
el: '#app',
data: {
themeSwitcher,
isDarkTheme: themeSwitcher.isDarkTheme,
loadingStates: {
fetched: false,
spinning: false
},
oldXraySetting: '',
xraySetting: '',
outboundTestUrl: 'https://www.google.com/generate_204',
oldOutboundTestUrl: 'https://www.google.com/generate_204',
inboundTags: [],
outboundsTraffic: [],
outboundTestStates: {}, // Track testing state and results for each outbound
saveBtnDisable: true,
refreshing: false,
restartResult: '',
showAlert: false,
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
advSettings: 'xraySetting',
obsSettings: '',
cm: null,
cmOptions: {
lineNumbers: true,
mode: "application/json",
lint: true,
styleActiveLine: true,
matchBrackets: true,
theme: "xq",
autoCloseTags: true,
lineWrapping: true,
indentUnit: 2,
indentWithTabs: true,
smartIndent: true,
tabSize: 2,
lineWiseCopyCut: false,
foldGutter: true,
gutters: [
"CodeMirror-lint-markers",
"CodeMirror-linenumbers",
"CodeMirror-foldgutter",
],
},
ipv4Settings: {
tag: "IPv4",
protocol: "freedom",
settings: {
domainStrategy: "UseIPv4"
}
},
directSettings: {
tag: "direct",
protocol: "freedom"
},
routingDomainStrategies: ["AsIs", "IPIfNonMatch", "IPOnDemand"],
log: {
loglevel: ["none", "debug", "info", "warning", "error"],
access: ["none", "./access.log"],
error: ["none", "./error.log"],
dnsLog: false,
maskAddress: ["quarter", "half", "full"],
},
settingsData: {
protocols: {
bittorrent: ["bittorrent"],
},
IPsOptions: [{
label: 'Private IPs',
value: 'geoip:private'
},
{
label: '🇮🇷 Iran',
value: 'ext:geoip_IR.dat:ir'
},
{
label: '🇨🇳 China',
value: 'geoip:cn'
},
{
label: '🇷🇺 Russia',
value: 'ext:geoip_RU.dat:ru'
},
{
label: '🇻🇳 Vietnam',
value: 'geoip:vn'
},
{
label: '🇪🇸 Spain',
value: 'geoip:es'
},
{
label: '🇮🇩 Indonesia',
value: 'geoip:id'
},
{
label: '🇺🇦 Ukraine',
value: 'geoip:ua'
},
{
label: '🇹🇷 Türkiye',
value: 'geoip:tr'
},
{
label: '🇧🇷 Brazil',
value: 'geoip:br'
},
],
DomainsOptions: [{
label: '🇮🇷 Iran',
value: 'ext:geosite_IR.dat:ir'
},
{
label: '🇮🇷 .ir',
value: 'regexp:.*\\.ir$'
},
{
label: '🇮🇷 .ایران',
value: 'regexp:.*\\.xn--mgba3a4f16a$'
},
{
label: '🇨🇳 China',
value: 'geosite:cn'
},
{
label: '🇨🇳 .cn',
value: 'regexp:.*\\.cn$'
},
{
label: '🇷🇺 Russia',
value: 'ext:geosite_RU.dat:ru-available-only-inside'
},
{
label: '🇷🇺 .ru',
value: 'regexp:.*\\.ru$'
},
{
label: '🇷🇺 .su',
value: 'regexp:.*\\.su$'
},
{
label: '🇷🇺 .рф',
value: 'regexp:.*\\.xn--p1ai$'
},
{
label: '🇻🇳 .vn',
value: 'regexp:.*\\.vn$'
},
],
BlockDomainsOptions: [{
label: 'Ads All',
value: 'geosite:category-ads-all'
},
{
label: 'Ads IR 🇮🇷',
value: 'ext:geosite_IR.dat:category-ads-all'
},
{
label: 'Ads RU 🇷🇺',
value: 'ext:geosite_RU.dat:category-ads-all'
},
{
label: 'Malware 🇮🇷',
value: 'ext:geosite_IR.dat:malware'
},
{
label: 'Phishing 🇮🇷',
value: 'ext:geosite_IR.dat:phishing'
},
{
label: 'Cryptominers 🇮🇷',
value: 'ext:geosite_IR.dat:cryptominers'
},
{
label: 'Adult +18',
value: 'geosite:category-porn'
},
{
label: '🇮🇷 Iran',
value: 'ext:geosite_IR.dat:ir'
},
{
label: '🇮🇷 .ir',
value: 'regexp:.*\\.ir$'
},
{
label: '🇮🇷 .ایران',
value: 'regexp:.*\\.xn--mgba3a4f16a$'
},
{
label: '🇨🇳 China',
value: 'geosite:cn'
},
{
label: '🇨🇳 .cn',
value: 'regexp:.*\\.cn$'
},
{
label: '🇷🇺 Russia',
value: 'ext:geosite_RU.dat:ru-available-only-inside'
},
{
label: '🇷🇺 .ru',
value: 'regexp:.*\\.ru$'
},
{
label: '🇷🇺 .su',
value: 'regexp:.*\\.su$'
},
{
label: '🇷🇺 .рф',
value: 'regexp:.*\\.xn--p1ai$'
},
{
label: '🇻🇳 .vn',
value: 'regexp:.*\\.vn$'
},
],
ServicesOptions: [{
label: 'Apple',
value: 'geosite:apple'
},
{
label: 'Meta',
value: 'geosite:meta'
},
{
label: 'Google',
value: 'geosite:google'
},
{
label: 'OpenAI',
value: 'geosite:openai'
},
{
label: 'Spotify',
value: 'geosite:spotify'
},
{
label: 'Netflix',
value: 'geosite:netflix'
},
{
label: 'Reddit',
value: 'geosite:reddit'
},
{
label: 'Speedtest',
value: 'geosite:speedtest'
},
]
},
defaultObservatory: {
subjectSelector: [],
probeURL: "https://www.google.com/generate_204",
probeInterval: "1m",
enableConcurrency: true
},
defaultBurstObservatory: {
subjectSelector: [],
pingConfig: {
destination: "https://www.google.com/generate_204",
interval: "1m",
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
timeout: "5s",
sampling: 2
}
}
},
methods: {
loading(spinning = true) {
this.loadingStates.spinning = spinning;
},
async getOutboundsTraffic() {
const msg = await HttpUtil.get("/panel/xray/getOutboundsTraffic");
if (msg.success) {
this.outboundsTraffic = msg.obj;
}
},
async getXraySetting() {
const msg = await HttpUtil.post("/panel/xray/");
if (msg.success) {
if (!this.loadingStates.fetched) {
this.loadingStates.fetched = true
}
result = JSON.parse(msg.obj);
xs = JSON.stringify(result.xraySetting, null, 2);
this.oldXraySetting = xs;
this.xraySetting = xs;
this.inboundTags = result.inboundTags;
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
this.oldOutboundTestUrl = this.outboundTestUrl;
this.saveBtnDisable = true;
}
},
async updateXraySetting() {
this.loading(true);
const msg = await HttpUtil.post("/panel/xray/update", {
xraySetting: this.xraySetting,
outboundTestUrl: this.outboundTestUrl || 'https://www.google.com/generate_204'
});
this.loading(false);
if (msg.success) {
await this.getXraySetting();
}
},
async restartXray() {
this.loading(true);
const msg = await HttpUtil.post("/panel/api/server/restartXrayService");
this.loading(false);
if (msg.success) {
await PromiseUtil.sleep(500);
await this.getXrayResult();
}
this.loading(false);
},
async getXrayResult() {
const msg = await HttpUtil.get("/panel/xray/getXrayResult");
if (msg.success) {
this.restartResult = msg.obj;
if (msg.obj.length > 1) Vue.prototype.$message.error(msg.obj);
}
},
async resetXrayConfigToDefault() {
this.loading(true);
const msg = await HttpUtil.get("/panel/setting/getDefaultJsonConfig");
this.loading(false);
if (msg.success) {
this.templateSettings = JSON.parse(JSON.stringify(msg.obj, null, 2));
this.saveBtnDisable = true;
}
},
changePage(pageKey) {
if (pageKey == 'tpl-advanced') this.changeCode();
if (pageKey == 'tpl-balancer') this.changeObsCode();
},
syncRulesWithOutbound(tag, setting) {
const newTemplateSettings = this.templateSettings;
const haveRules = newTemplateSettings.routing.rules.some((r) => r?.outboundTag === tag);
const outboundIndex = newTemplateSettings.outbounds.findIndex((o) => o.tag === tag);
if (!haveRules && outboundIndex > 0) {
newTemplateSettings.outbounds.splice(outboundIndex);
}
if (haveRules && outboundIndex < 0) {
newTemplateSettings.outbounds.push(setting);
}
this.templateSettings = newTemplateSettings;
},
templateRuleGetter(routeSettings) {
const {
property,
outboundTag
} = routeSettings;
let result = [];
if (this.templateSettings != null) {
this.templateSettings.routing.rules.forEach(
(routingRule) => {
if (
routingRule.hasOwnProperty(property) &&
routingRule.hasOwnProperty("outboundTag") &&
routingRule.outboundTag === outboundTag
) {
result.push(...routingRule[property]);
}
}
);
}
return result;
},
templateRuleSetter(routeSettings) {
const {
data,
property,
outboundTag
} = routeSettings;
const oldTemplateSettings = this.templateSettings;
const newTemplateSettings = oldTemplateSettings;
currentProperty = this.templateRuleGetter({
outboundTag,
property
})
if (currentProperty.length == 0) {
const propertyRule = {
type: "field",
outboundTag,
[property]: data
};
newTemplateSettings.routing.rules.push(propertyRule);
} else {
const newRules = [];
insertedOnce = false;
newTemplateSettings.routing.rules.forEach(
(routingRule) => {
if (
routingRule.hasOwnProperty(property) &&
routingRule.hasOwnProperty("outboundTag") &&
routingRule.outboundTag === outboundTag
) {
if (!insertedOnce && data.length > 0) {
insertedOnce = true;
routingRule[property] = data;
newRules.push(routingRule);
}
} else {
newRules.push(routingRule);
}
}
);
newTemplateSettings.routing.rules = newRules;
}
this.templateSettings = newTemplateSettings;
},
changeCode() {
if (this.cm != null) {
this.cm.toTextArea();
}
textAreaObj = document.getElementById('xraySetting');
textAreaObj.value = this[this.advSettings];
this.cm = CodeMirror.fromTextArea(textAreaObj, this.cmOptions);
this.cm.on('change', editor => {
value = editor.getValue();
if (this.isJsonString(value)) {
this[this.advSettings] = value;
}
});
},
changeObsCode() {
if (this.cm != null) {
this.cm.toTextArea();
}
if (this.obsSettings == '') {
this.cm = null;
return
}
textAreaObj = document.getElementById('obsSetting');
textAreaObj.value = this[this.obsSettings];
this.cm = CodeMirror.fromTextArea(textAreaObj, this.cmOptions);
this.cm.on('change', editor => {
value = editor.getValue();
if (this.isJsonString(value)) {
this[this.obsSettings] = value;
}
});
},
isJsonString(str) {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
},
// outboundTrafficFor returns {up, down} for an outbound by tag,
// defaulting to zeros when no traffic row has been reported yet.
// Templates use the up/down accessors below — keeping the lookup in
// one place avoids drift if the data shape changes.
outboundTrafficFor(o) {
const t = this.outboundsTraffic.find(t => t.tag == o.tag);
return { up: t ? t.up : 0, down: t ? t.down : 0 };
},
findOutboundUp(o) { return this.outboundTrafficFor(o).up; },
findOutboundDown(o) { return this.outboundTrafficFor(o).down; },
// One tone per category instead of per-value. Adding a new protocol or
// transport inherits the category colour — no styling work required.
// Hierarchy: emerald (protocol — primary identity, matches brand) →
// slate (network — transport is plumbing, sits back) → violet (security —
// accent, only rendered for tls/reality so a stand-out hue is earned).
outboundProtocolTone() { return 'tone-emerald'; },
outboundNetworkTone() { return 'tone-slate'; },
outboundSecurityTone() { return 'tone-violet'; },
// Whether the security label is one we render as a pill in the table.
isOutboundSecurityVisible(security) {
return security === 'tls' || security === 'reality';
},
// Null-safe accessor for the address list — collapses null/undefined
// returns from findOutboundAddress() into an empty array so the template
// can rely on .length and v-for without extra guards.
outboundAddresses(o) {
return this.findOutboundAddress(o) || [];
},
// Test-state accessors — sparse arrays + per-row state make raw checks
// verbose; these helpers keep the template readable and consistent.
isOutboundTesting(index) {
const s = this.outboundTestStates[index];
return !!(s && s.testing);
},
outboundTestResult(index) {
const s = this.outboundTestStates[index];
return s ? s.result : null;
},
isOutboundUntestable(outbound) {
return outbound.protocol === 'blackhole' || outbound.tag === 'blocked';
},
// csv splits a comma-separated rule field into trimmed non-empty values.
// Routing rule data uses CSV strings for multi-value criteria (e.g.
// sourceIP "1.2.3.0/24,4.5.6.0/24"); the modern table renders each
// criterion as a single summary pill, so values are normally re-joined
// via joinCsv() but this helper is kept for callers that need an array.
csv(value) {
if (!value) return [];
return String(value)
.split(',')
.map(v => v.trim())
.filter(v => v.length > 0);
},
// joinCsv normalises a CSV-style rule field into a single comma-space
// separated string suitable for tooltips. Returns '' for empty inputs
// so v-if guards can short-circuit on the raw rule field.
joinCsv(value) {
return this.csv(value).join(', ');
},
findOutboundAddress(o) {
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(peer => peer.endpoint);
default:
return null;
}
return serverObj ? serverObj.map(obj => obj.address + ':' + obj.port) : null;
},
addOutbound() {
outModal.show({
title: '{{ i18n "pages.xray.outbound.addOutbound"}}',
okText: '{{ i18n "pages.xray.outbound.addOutbound" }}',
confirm: (outbound) => {
outModal.loading();
if (outbound.tag.length > 0) {
this.templateSettings.outbounds.push(outbound);
this.outboundSettings = JSON.stringify(this.templateSettings.outbounds);
}
outModal.close();
},
isEdit: false,
tags: this.templateSettings.outbounds.map(obj => obj.tag)
});
},
editOutbound(index) {
outModal.show({
title: '{{ i18n "pages.xray.outbound.editOutbound"}} ' + (index + 1),
outbound: app.templateSettings.outbounds[index],
confirm: (outbound) => {
outModal.loading();
this.templateSettings.outbounds[index] = outbound;
this.outboundSettings = JSON.stringify(this.templateSettings.outbounds);
outModal.close();
},
isEdit: true,
tags: this.outboundData.filter((o) => o.key != index).map(obj => obj.tag)
});
},
deleteOutbound(index) {
outbounds = this.templateSettings.outbounds;
outbounds.splice(index, 1);
this.outboundSettings = JSON.stringify(outbounds);
},
setFirstOutbound(index) {
outbounds = this.templateSettings.outbounds;
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
this.outboundSettings = JSON.stringify(outbounds);
},
async testOutbound(index) {
const outbound = this.templateSettings.outbounds[index];
if (!outbound) {
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}');
return;
}
if (outbound.protocol === 'blackhole' || outbound.tag === 'blocked') {
Vue.prototype.$message.warning(
'{{ i18n "pages.xray.outbound.testError" }}: blocked/blackhole outbound');
return;
}
// Initialize test state for this outbound if not exists
if (!this.outboundTestStates[index]) {
this.$set(this.outboundTestStates, index, {
testing: false,
result: null
});
}
// Set testing state
this.$set(this.outboundTestStates[index], 'testing', true);
this.$set(this.outboundTestStates[index], 'result', null);
try {
const outboundJSON = JSON.stringify(outbound);
const allOutboundsJSON = JSON.stringify(this.templateSettings.outbounds || []);
const msg = await HttpUtil.post("/panel/xray/testOutbound", {
outbound: outboundJSON,
allOutbounds: allOutboundsJSON
});
// Update test state
this.$set(this.outboundTestStates[index], 'testing', false);
if (msg.success && msg.obj) {
const result = msg.obj;
this.$set(this.outboundTestStates[index], 'result', result);
if (result.success) {
Vue.prototype.$message.success(
`{{ i18n "pages.xray.outbound.testSuccess" }}: ${result.delay}ms (${result.statusCode})`
);
} else {
Vue.prototype.$message.error(
`{{ i18n "pages.xray.outbound.testFailed" }}: ${result.error || 'Unknown error'}`
);
}
} else {
this.$set(this.outboundTestStates[index], 'result', {
success: false,
error: msg.msg || '{{ i18n "pages.xray.outbound.testError" }}'
});
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.xray.outbound.testError" }}');
}
} catch (error) {
this.$set(this.outboundTestStates[index], 'testing', false);
this.$set(this.outboundTestStates[index], 'result', {
success: false,
error: error.message || '{{ i18n "pages.xray.outbound.testError" }}'
});
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}: ' + error.message);
}
},
async refreshOutboundTraffic() {
if (!this.refreshing) {
this.refreshing = true;
await this.getOutboundsTraffic();
data = []
if (this.templateSettings != null) {
this.templateSettings.outbounds.forEach((o, index) => {
data.push({
'key': index,
...o
});
});
}
this.outboundData = data;
this.refreshing = false;
}
},
async resetOutboundTraffic(index) {
let tag = "-alltags-";
if (index >= 0) {
tag = this.outboundData[index].tag ? this.outboundData[index].tag : ""
}
const msg = await HttpUtil.post("/panel/xray/resetOutboundsTraffic", {
tag: tag
});
if (msg.success) {
await this.refreshOutboundTraffic();
}
},
addBalancer() {
balancerModal.show({
title: '{{ i18n "pages.xray.balancer.addBalancer"}}',
okText: '{{ i18n "pages.xray.balancer.addBalancer"}}',
balancerTags: this.balancersData.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag),
balancer: {
tag: '',
strategy: 'random',
selector: [],
fallbackTag: ''
},
confirm: (balancer) => {
balancerModal.loading();
newTemplateSettings = this.templateSettings;
if (newTemplateSettings.routing.balancers == undefined) {
newTemplateSettings.routing.balancers = [];
}
let tmpBalancer = {
'tag': balancer.tag,
'selector': balancer.selector,
'fallbackTag': balancer.fallbackTag
};
if (balancer.strategy && balancer.strategy != 'random') {
tmpBalancer.strategy = {
'type': balancer.strategy
};
}
newTemplateSettings.routing.balancers.push(tmpBalancer);
this.templateSettings = newTemplateSettings;
this.updateObservatorySelectors();
balancerModal.close();
this.changeObsCode();
},
isEdit: false
});
},
editBalancer(index) {
const oldTag = this.balancersData[index].tag;
balancerModal.show({
title: '{{ i18n "pages.xray.balancer.editBalancer"}}',
okText: '{{ i18n "sure" }}',
balancerTags: this.balancersData.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag),
balancer: this.balancersData[index],
confirm: (balancer) => {
balancerModal.loading();
newTemplateSettings = this.templateSettings;
let tmpBalancer = {
'tag': balancer.tag,
'selector': balancer.selector,
'fallbackTag': balancer.fallbackTag
};
// Remove old tag
if (newTemplateSettings.observatory) {
newTemplateSettings.observatory.subjectSelector = newTemplateSettings.observatory
.subjectSelector.filter(s => s != oldTag);
}
if (newTemplateSettings.burstObservatory) {
newTemplateSettings.burstObservatory.subjectSelector = newTemplateSettings.burstObservatory
.subjectSelector.filter(s => s != oldTag);
}
if (balancer.strategy && balancer.strategy != 'random') {
tmpBalancer.strategy = {
'type': balancer.strategy
};
}
newTemplateSettings.routing.balancers[index] = tmpBalancer;
// change edited tag if used in rule section
if (oldTag != balancer.tag) {
newTemplateSettings.routing.rules.forEach((rule) => {
if (rule.balancerTag && rule.balancerTag == oldTag) {
rule.balancerTag = balancer.tag;
}
});
}
this.templateSettings = newTemplateSettings;
this.updateObservatorySelectors();
balancerModal.close();
this.changeObsCode();
},
isEdit: true
});
},
updateObservatorySelectors() {
newTemplateSettings = this.templateSettings;
const leastPings = this.balancersData.filter((b) => b.strategy == 'leastPing');
const leastLoads = this.balancersData.filter((b) =>
b.strategy === 'leastLoad' ||
b.strategy === 'roundRobin' ||
b.strategy === 'random'
);
if (leastPings.length > 0) {
if (!newTemplateSettings.observatory)
newTemplateSettings.observatory = this.defaultObservatory;
newTemplateSettings.observatory.subjectSelector = [];
leastPings.forEach((b) => {
b.selector.forEach((s) => {
if (!newTemplateSettings.observatory.subjectSelector.includes(s))
newTemplateSettings.observatory.subjectSelector.push(s);
});
});
} else {
delete newTemplateSettings.observatory
}
if (leastLoads.length > 0) {
if (!newTemplateSettings.burstObservatory)
newTemplateSettings.burstObservatory = this.defaultBurstObservatory;
newTemplateSettings.burstObservatory.subjectSelector = [];
leastLoads.forEach((b) => {
b.selector.forEach((s) => {
if (!newTemplateSettings.burstObservatory.subjectSelector.includes(s))
newTemplateSettings.burstObservatory.subjectSelector.push(s);
});
});
} else {
delete newTemplateSettings.burstObservatory
}
this.templateSettings = newTemplateSettings;
this.changeObsCode();
},
deleteBalancer(index) {
newTemplateSettings = this.templateSettings;
// Remove from balancers
const removedBalancer = this.balancersData.splice(index, 1)[0];
// Remove from settings
let realIndex = newTemplateSettings.routing.balancers.findIndex((b) => b.tag === removedBalancer.tag);
newTemplateSettings.routing.balancers.splice(realIndex, 1);
// Update balancers property to an empty array if there are no more balancers
if (newTemplateSettings.routing.balancers.length === 0) {
delete newTemplateSettings.routing.balancers;
}
// Remove orphaned balancer references from routing rules
if (newTemplateSettings.routing.rules) {
newTemplateSettings.routing.rules.forEach((rule) => {
if (rule.balancerTag && rule.balancerTag === removedBalancer.tag) {
delete rule.balancerTag;
}
});
}
this.templateSettings = newTemplateSettings;
this.updateObservatorySelectors();
this.obsSettings = '';
this.changeObsCode()
},
openDNSPresets() {
dnsPresetsModal.show({
title: '{{ i18n "pages.xray.dns.dnsPresetTitle" }}',
selected: (selectedPreset) => {
this.dnsServers = selectedPreset;
dnsPresetsModal.close();
}
});
},
addDNSServer() {
dnsModal.show({
title: '{{ i18n "pages.xray.dns.add" }}',
confirm: (dnsServer) => {
dnsServers = this.dnsServers;
dnsServers.push(dnsServer);
this.dnsServers = dnsServers;
dnsModal.close();
},
isEdit: false
});
},
editDNSServer(index) {
dnsModal.show({
title: '{{ i18n "pages.xray.dns.edit" }} #' + (index + 1),
dnsServer: this.dnsServers[index],
confirm: (dnsServer) => {
dnsServers = this.dnsServers;
dnsServers[index] = dnsServer;
this.dnsServers = dnsServers;
dnsModal.close();
},
isEdit: true
});
},
deleteDNSServer(index) {
newDnsServers = this.dnsServers;
newDnsServers.splice(index, 1);
this.dnsServers = newDnsServers;
},
addFakedns() {
fakednsModal.show({
title: '{{ i18n "pages.xray.fakedns.add" }}',
confirm: (item) => {
fakeDns = this.fakeDns ?? [];
fakeDns.push(item);
this.fakeDns = fakeDns;
fakednsModal.close();
},
isEdit: false
});
},
editFakedns(index) {
fakednsModal.show({
title: '{{ i18n "pages.xray.fakedns.edit" }} #' + (index + 1),
fakeDns: this.fakeDns[index],
confirm: (item) => {
fakeDns = this.fakeDns;
fakeDns[index] = item;
this.fakeDns = fakeDns;
fakednsModal.close();
},
isEdit: true
});
},
deleteFakedns(index) {
fakeDns = this.fakeDns;
fakeDns.splice(index, 1);
this.fakeDns = fakeDns;
},
addRule() {
ruleModal.show({
title: '{{ i18n "pages.xray.rules.add"}}',
okText: '{{ i18n "pages.xray.rules.add" }}',
confirm: (rule) => {
ruleModal.loading();
if (JSON.stringify(rule).length > 3) {
this.templateSettings.routing.rules.push(rule);
this.routingRuleSettings = JSON.stringify(this.templateSettings.routing.rules);
}
ruleModal.close();
},
isEdit: false
});
},
editRule(index) {
ruleModal.show({
title: '{{ i18n "pages.xray.rules.edit"}} ' + (index + 1),
rule: app.templateSettings.routing.rules[index],
confirm: (rule) => {
ruleModal.loading();
if (JSON.stringify(rule).length > 3) {
this.templateSettings.routing.rules[index] = rule;
this.routingRuleSettings = JSON.stringify(this.templateSettings.routing.rules);
}
ruleModal.close();
},
isEdit: true
});
},
replaceRule(old_index, new_index) {
rules = this.templateSettings.routing.rules;
if (new_index >= rules.length) rules.push(undefined);
rules.splice(new_index, 0, rules.splice(old_index, 1)[0]);
this.routingRuleSettings = JSON.stringify(rules);
},
deleteRule(index) {
rules = this.templateSettings.routing.rules;
rules.splice(index, 1);
this.routingRuleSettings = JSON.stringify(rules);
},
showWarp() {
warpModal.show();
},
showNord() {
nordModal.show();
},
async loadCustomGeoAliases() {
try {
const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
if (!msg.success) {
console.warn('Failed to load custom geo aliases:', msg.msg || 'request failed');
return;
}
if (!msg.obj) return;
const geoip = msg.obj.geoip ?? [];
const geosite = msg.obj.geosite ?? [];
const geoSuffix = this.customGeoAliasLabelSuffix || '';
geoip.forEach((x) => {
this.settingsData.IPsOptions.push({
label: x.alias + geoSuffix,
value: x.extExample,
});
});
geosite.forEach((x) => {
const opt = {
label: x.alias + geoSuffix,
value: x.extExample
};
this.settingsData.DomainsOptions.push(opt);
this.settingsData.BlockDomainsOptions.push(opt);
});
} catch (e) {
console.error('Failed to load custom geo aliases:', e);
}
}
},
async mounted() {
if (window.location.protocol !== "https:") {
this.showAlert = true;
}
await this.getXraySetting();
await this.loadCustomGeoAliases();
await this.getXrayResult();
await this.getOutboundsTraffic();
if (window.wsClient) {
window.wsClient.connect();
window.wsClient.on('outbounds', (payload) => {
if (payload) {
this.outboundsTraffic = payload;
this.$forceUpdate();
}
});
// Handle invalidate signals (sent when payload is too large for WebSocket,
// or when traffic job notifies about data changes)
window.wsClient.on('invalidate', (payload) => {
if (payload && payload.type === 'outbounds') {
this.refreshOutboundTraffic();
}
});
}
while (true) {
await PromiseUtil.sleep(800);
this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this
.outboundTestUrl;
}
},
computed: {
templateSettings: {
get: function() {
const parsedSettings = this.xraySetting ? JSON.parse(this.xraySetting) : null;
return parsedSettings;
},
set: function(newValue) {
if (newValue) {
this.xraySetting = JSON.stringify(newValue, null, 2);
}
},
},
inboundSettings: {
get: function() {
return this.templateSettings ? JSON.stringify(this.templateSettings.inbounds, null, 2) : null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.inbounds = JSON.parse(newValue);
this.templateSettings = newTemplateSettings;
},
},
outboundSettings: {
get: function() {
return this.templateSettings ? JSON.stringify(this.templateSettings.outbounds, null, 2) : null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.outbounds = JSON.parse(newValue);
this.templateSettings = newTemplateSettings;
},
},
outboundData: {
get: function() {
data = []
if (this.templateSettings != null) {
this.templateSettings.outbounds.forEach((o, index) => {
data.push({
'key': index,
...o
});
});
}
return data;
},
},
routingRuleSettings: {
get: function() {
return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.routing.rules = JSON.parse(newValue);
this.templateSettings = newTemplateSettings;
},
},
routingRuleData: {
get: function() {
data = [];
if (this.templateSettings != null) {
this.templateSettings.routing.rules.forEach((r, index) => {
data.push({
'key': index,
...r
});
});
// Make rules readable
data.forEach(r => {
if (r.domain) r.domain = r.domain.join(',')
if (r.ip) r.ip = r.ip.join(',')
if (r.source) r.source = r.source.join(',');
if (r.user) r.user = r.user.join(',')
if (r.inboundTag) r.inboundTag = r.inboundTag.join(',')
if (r.protocol) r.protocol = r.protocol.join(',')
if (r.attrs) r.attrs = JSON.stringify(r.attrs, null, 2)
});
}
return data;
}
},
balancersData: {
get: function() {
data = []
if (this.templateSettings != null && this.templateSettings.routing != null && this.templateSettings
.routing.balancers != null) {
this.templateSettings.routing.balancers.forEach((o, index) => {
data.push({
'key': index,
'tag': o.tag ? o.tag : "",
'strategy': o.strategy?.type ?? "random",
'selector': o.selector ? o.selector : [],
'fallbackTag': o.fallbackTag ?? '',
});
});
}
return data;
}
},
observatory: {
get: function() {
return this.templateSettings?.observatory ? JSON.stringify(this.templateSettings.observatory, null, 2) :
null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.observatory = JSON.parse(newValue);
this.templateSettings = newTemplateSettings;
},
},
burstObservatory: {
get: function() {
return this.templateSettings?.burstObservatory ? JSON.stringify(this.templateSettings.burstObservatory,
null, 2) : null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.burstObservatory = JSON.parse(newValue);
this.templateSettings = newTemplateSettings;
},
},
observatoryEnable: function() {
return this.templateSettings != null && this.templateSettings.observatory != undefined
},
burstObservatoryEnable: function() {
return this.templateSettings != null && this.templateSettings.burstObservatory != undefined
},
freedomStrategy: {
get: function() {
if (!this.templateSettings) return "AsIs";
freedomOutbound = this.templateSettings.outbounds.find((o) => o.protocol === "freedom" && o.tag ==
"direct");
if (!freedomOutbound) return "AsIs";
if (!freedomOutbound.settings || !freedomOutbound.settings.domainStrategy) return "AsIs";
return freedomOutbound.settings.domainStrategy;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
freedomOutboundIndex = newTemplateSettings.outbounds.findIndex((o) => o.protocol === "freedom" && o
.tag == "direct");
if (freedomOutboundIndex == -1) {
newTemplateSettings.outbounds.push({
protocol: "freedom",
tag: "direct",
settings: {
"domainStrategy": newValue
}
});
} else if (!newTemplateSettings.outbounds[freedomOutboundIndex].settings) {
newTemplateSettings.outbounds[freedomOutboundIndex].settings = {
"domainStrategy": newValue
};
} else {
newTemplateSettings.outbounds[freedomOutboundIndex].settings.domainStrategy = newValue;
}
this.templateSettings = newTemplateSettings;
}
},
routingStrategy: {
get: function() {
if (!this.templateSettings || !this.templateSettings.routing || !this.templateSettings.routing
.domainStrategy) return "AsIs";
return this.templateSettings.routing.domainStrategy;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.routing.domainStrategy = newValue;
this.templateSettings = newTemplateSettings;
}
},
logLevel: {
get: function() {
if (!this.templateSettings || !this.templateSettings.log || !this.templateSettings.log.loglevel)
return "warning";
return this.templateSettings.log.loglevel;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.log.loglevel = newValue;
this.templateSettings = newTemplateSettings;
}
},
accessLog: {
get: function() {
if (!this.templateSettings || !this.templateSettings.log || !this.templateSettings.log.access)
return "";
return this.templateSettings.log.access;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.log.access = newValue;
this.templateSettings = newTemplateSettings;
}
},
errorLog: {
get: function() {
if (!this.templateSettings || !this.templateSettings.log || !this.templateSettings.log.error) return "";
return this.templateSettings.log.error;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.log.error = newValue;
this.templateSettings = newTemplateSettings;
}
},
dnslog: {
get: function() {
if (!this.templateSettings || !this.templateSettings.log || !this.templateSettings.log.dnsLog)
return false;
return this.templateSettings.log.dnsLog;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.log.dnsLog = newValue;
this.templateSettings = newTemplateSettings;
}
},
statsInboundUplink: {
get: function() {
if (!this.templateSettings || !this.templateSettings.policy.system || !this.templateSettings.policy
.system.statsInboundUplink) return false;
return this.templateSettings.policy.system.statsInboundUplink;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.policy.system.statsInboundUplink = newValue;
this.templateSettings = newTemplateSettings;
}
},
statsInboundDownlink: {
get: function() {
if (!this.templateSettings || !this.templateSettings.policy.system || !this.templateSettings.policy
.system.statsInboundDownlink) return false;
return this.templateSettings.policy.system.statsInboundDownlink;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.policy.system.statsInboundDownlink = newValue;
this.templateSettings = newTemplateSettings;
}
},
statsOutboundUplink: {
get: function() {
if (!this.templateSettings || !this.templateSettings.policy.system || !this.templateSettings.policy
.system.statsOutboundUplink) return false;
return this.templateSettings.policy.system.statsOutboundUplink;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.policy.system.statsOutboundUplink = newValue;
this.templateSettings = newTemplateSettings;
}
},
statsOutboundDownlink: {
get: function() {
if (!this.templateSettings || !this.templateSettings.policy.system || !this.templateSettings.policy
.system.statsOutboundDownlink) return false;
return this.templateSettings.policy.system.statsOutboundDownlink;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.policy.system.statsOutboundDownlink = newValue;
this.templateSettings = newTemplateSettings;
}
},
maskAddressLog: {
get: function() {
if (!this.templateSettings || !this.templateSettings.log || !this.templateSettings.log.maskAddress)
return "";
return this.templateSettings.log.maskAddress;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.log.maskAddress = newValue;
this.templateSettings = newTemplateSettings;
}
},
blockedIPs: {
get: function() {
return this.templateRuleGetter({
outboundTag: "blocked",
property: "ip"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "blocked",
property: "ip",
data: newValue
});
}
},
blockedDomains: {
get: function() {
return this.templateRuleGetter({
outboundTag: "blocked",
property: "domain"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "blocked",
property: "domain",
data: newValue
});
}
},
blockedProtocols: {
get: function() {
return this.templateRuleGetter({
outboundTag: "blocked",
property: "protocol"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "blocked",
property: "protocol",
data: newValue
});
}
},
directIPs: {
get: function() {
return this.templateRuleGetter({
outboundTag: "direct",
property: "ip"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "direct",
property: "ip",
data: newValue
});
this.syncRulesWithOutbound("direct", this.directSettings);
}
},
directDomains: {
get: function() {
return this.templateRuleGetter({
outboundTag: "direct",
property: "domain"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "direct",
property: "domain",
data: newValue
});
this.syncRulesWithOutbound("direct", this.directSettings);
}
},
ipv4Domains: {
get: function() {
return this.templateRuleGetter({
outboundTag: "IPv4",
property: "domain"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "IPv4",
property: "domain",
data: newValue
});
this.syncRulesWithOutbound("IPv4", this.ipv4Settings);
}
},
warpDomains: {
get: function() {
return this.templateRuleGetter({
outboundTag: "warp",
property: "domain"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: "warp",
property: "domain",
data: newValue
});
}
},
nordTag: {
get: function() {
return this.templateSettings ? (this.templateSettings.outbounds.find((o) => o.tag.startsWith(
"nord-")) || {
tag: "nord"
}).tag : "nord";
}
},
nordDomains: {
get: function() {
return this.templateRuleGetter({
outboundTag: this.nordTag,
property: "domain"
});
},
set: function(newValue) {
this.templateRuleSetter({
outboundTag: this.nordTag,
property: "domain",
data: newValue
});
}
},
torrentSettings: {
get: function() {
return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
},
set: function(newValue) {
if (newValue) {
this.blockedProtocols = [...this.blockedProtocols, ...this.settingsData.protocols.bittorrent];
} else {
this.blockedProtocols = this.blockedProtocols.filter(data => !this.settingsData.protocols.bittorrent
.includes(data));
}
},
},
WarpExist: {
get: function() {
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 :
false;
},
},
NordExist: {
get: function() {
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag.startsWith(
"nord-")) >= 0 : false;
},
},
enableDNS: {
get: function() {
return this.templateSettings ? this.templateSettings.dns != null : false;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns = {
servers: [],
queryStrategy: "UseIP",
tag: "dns_inbound",
enableParallelQuery: false
};
newTemplateSettings.fakedns = null;
} else {
delete newTemplateSettings.dns;
delete newTemplateSettings.fakedns;
}
this.templateSettings = newTemplateSettings;
}
},
dnsTag: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.tag : "";
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.dns.tag = newValue;
this.templateSettings = newTemplateSettings;
}
},
dnsClientIp: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.clientIp : null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns.clientIp = newValue;
} else {
delete newTemplateSettings.dns.clientIp;
}
this.templateSettings = newTemplateSettings;
}
},
dnsDisableCache: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.disableCache : false;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns.disableCache = newValue;
} else {
delete newTemplateSettings.dns.disableCache
}
this.templateSettings = newTemplateSettings;
}
},
dnsDisableFallback: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.disableFallback : false;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns.disableFallback = newValue;
} else {
delete newTemplateSettings.dns.disableFallback
}
this.templateSettings = newTemplateSettings;
}
},
dnsDisableFallbackIfMatch: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.disableFallbackIfMatch : false;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns.disableFallbackIfMatch = newValue;
} else {
delete newTemplateSettings.dns.disableFallbackIfMatch
}
this.templateSettings = newTemplateSettings;
}
},
dnsEnableParallelQuery: {
get: function() {
return this.enableDNS ? (this.templateSettings.dns.enableParallelQuery || false) : false;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns.enableParallelQuery = newValue;
} else {
delete newTemplateSettings.dns.enableParallelQuery
}
this.templateSettings = newTemplateSettings;
}
},
dnsUseSystemHosts: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.useSystemHosts : false;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
newTemplateSettings.dns.useSystemHosts = newValue;
} else {
delete newTemplateSettings.dns.useSystemHosts
}
this.templateSettings = newTemplateSettings;
}
},
dnsStrategy: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.queryStrategy : null;
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.dns.queryStrategy = newValue;
this.templateSettings = newTemplateSettings;
}
},
dnsServers: {
get: function() {
return this.enableDNS ? this.templateSettings.dns.servers : [];
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.dns.servers = newValue;
this.templateSettings = newTemplateSettings;
}
},
fakeDns: {
get: function() {
return this.templateSettings && this.templateSettings.fakedns ? this.templateSettings.fakedns : [];
},
set: function(newValue) {
newTemplateSettings = this.templateSettings;
if (this.enableDNS) {
newTemplateSettings.fakedns = newValue.length > 0 ? newValue : null;
} else {
delete newTemplateSettings.fakedns;
}
this.templateSettings = newTemplateSettings;
}
}
},
});
</script>
<style>
/* ───────── Modern outbounds table ─────────
Visual goals:
• flat surface, no inner cell borders, only subtle row dividers
• rounded pill badges for protocol / tag / addresses
• dual-arrow traffic widget that aligns across rows
• consistent hover/loading/result states
Scoped under .xray-page .outbounds-modern so it doesn't bleed into other tables. */
.xray-page .outbounds-modern { width: 100%; }
.xray-page .outbounds-toolbar-right { text-align: right; }
/* Table chrome */
.xray-page .outbounds-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .outbounds-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .outbounds-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .outbounds-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: middle;
}
/* Force every cell to honour its column width — long content (especially
long tags) must clip via cell-level ellipsis instead of pushing the row
taller. */
.xray-page .outbounds-table .ant-table-tbody > tr > td,
.xray-page .outbounds-table .ant-table-thead > tr > th {
overflow: hidden;
}
.light .xray-page .outbounds-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .outbounds-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Index + actions column */
.xray-page .outbound-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .outbound-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .outbound-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .outbound-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .outbound-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .outbound-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .outbound-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Identity cell — tag on top, protocol/network/security pills underneath.
Combining the two columns lets the table fit common viewports without
a horizontal scrollbar. */
.xray-page .outbound-identity-cell {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
/* Tag — inherits the table's font for visual parity, single line with
ellipsis on overflow. A long tag (e.g. "vless_jphttp-ksjpnggl") would
otherwise wrap and inflate the row's height; the inline tooltip surfaces
the full value on hover. */
.xray-page .outbound-tag {
font-size: 13px;
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.light .xray-page .outbound-tag { color: rgba(0, 0, 0, 0.85); }
/* Address pills (monospace, monoline) */
.xray-page .outbound-address-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.xray-page .outbound-address-pill {
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
font-size: 12px;
padding: 3px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.78);
line-height: 1.5;
border: 1px solid rgba(255, 255, 255, 0.06);
display: inline-block;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.light .xray-page .outbound-address-pill {
background: rgba(0, 0, 0, 0.035);
color: rgba(0, 0, 0, 0.78);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .outbound-address-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
/* Protocol/network/tls pills — shared "outbound-pill" with tonal modifiers.
The pill row stays on a single line; if the column is somehow too narrow
for all pills it overflows out of view (rare — column width is sized to
fit the worst case) but never pushes the row taller. */
.xray-page .outbound-protocol-cell {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
overflow: hidden;
}
.xray-page .outbound-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 9px;
border-radius: 11px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.01em;
border: 1px solid transparent;
white-space: nowrap;
flex: 0 0 auto;
}
/* Outbound pill tones: emerald = protocol, slate = network, violet = security.
tone-emerald and tone-violet are also consumed by routing.html for the
outboundTag / balancerTag pills. */
.xray-page .outbound-pill.tone-emerald { background: rgba(0, 191, 165, 0.14); color: #4dd4be; border-color: rgba(0, 191, 165, 0.28); }
.xray-page .outbound-pill.tone-slate { background: rgba(160, 174, 192, 0.14); color: #b8c2d0; border-color: rgba(160, 174, 192, 0.26); }
.xray-page .outbound-pill.tone-violet { background: rgba(155, 89, 219, 0.16); color: #b489e8; border-color: rgba(155, 89, 219, 0.32); }
/* Traffic — dual arrow widget, fixed columns so all rows align */
.xray-page .outbound-traffic-cell {
display: inline-grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
padding: 5px 12px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.04);
font-variant-numeric: tabular-nums;
font-size: 13px;
min-width: 0;
}
.light .xray-page .outbound-traffic-cell {
background: rgba(0, 0, 0, 0.035);
}
.xray-page .outbound-traffic-up,
.xray-page .outbound-traffic-down {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.xray-page .outbound-traffic-up { justify-content: flex-end; color: #4dd4be; }
.xray-page .outbound-traffic-down { justify-content: flex-start; color: #82a7ee; }
.xray-page .outbound-traffic-up .anticon,
.xray-page .outbound-traffic-down .anticon { font-size: 11px; }
.xray-page .outbound-traffic-sep {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.12);
border-radius: 1px;
}
.light .xray-page .outbound-traffic-sep { background: rgba(0, 0, 0, 0.12); }
/* Test result pills */
.xray-page .outbound-result-cell { display: inline-flex; }
.xray-page .outbound-result-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 100px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
border: 1px solid transparent;
}
.xray-page .outbound-result-pill .anticon { font-size: 12px; }
.xray-page .outbound-result-ok {
background: rgba(0, 191, 165, 0.14);
color: #4dd4be;
border-color: rgba(0, 191, 165, 0.28);
}
.xray-page .outbound-result-fail {
background: rgba(255, 77, 79, 0.14);
color: #ff7a7c;
border-color: rgba(255, 77, 79, 0.32);
}
.xray-page .outbound-result-status { opacity: 0.75; }
.xray-page .outbound-result-loading,
.xray-page .outbound-result-idle {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
}
.light .xray-page .outbound-result-loading,
.light .xray-page .outbound-result-idle { color: rgba(0, 0, 0, 0.4); }
/* Test button — sleek circular with subtle glow */
.xray-page .outbound-test-btn {
box-shadow: 0 2px 8px rgba(0, 191, 165, 0.18);
transition: transform 0.12s ease, box-shadow 0.18s ease;
}
.xray-page .outbound-test-btn:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0, 191, 165, 0.32);
}
.xray-page .outbound-test-btn[disabled] {
box-shadow: none;
opacity: 0.45;
}
/* ───────── Modern routing-rules table ─────────
Reuses the .outbound-pill tonal primitive (identical visual) so the
routing tab feels like the same panel as outbounds. Each cell groups
a routing criterion (Source / Network / Destination / Inbound) and
shows its values as labelled pills. */
.xray-page .routing-modern { width: 100%; }
.xray-page .routing-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: top;
}
.light .xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .routing-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Sort handle / # / actions */
.xray-page .routing-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .routing-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .routing-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .routing-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .routing-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .routing-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Plain-text criterion rows — replaces pill primitives in condition
columns. Each criterion is a row of "label value (+N)" with form-label
styling on the label. No bg, no border, no color tones — keeps cells
light and lets the column header carry the type semantic. The cell's
visual weight is now proportional only to the data length, not to
decoration. The single colored pill in Outbound/Balancer remains as
the row's focal point. */
.xray-page .criterion-flow {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.xray-page .criterion-row {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
font-size: 13px;
line-height: 1.5;
}
.xray-page .criterion-label {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.light .xray-page .criterion-label { color: rgba(0, 0, 0, 0.45); }
.xray-page .criterion-value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.85);
}
.light .xray-page .criterion-value { color: rgba(0, 0, 0, 0.85); }
.xray-page .criterion-more {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 500;
}
.light .xray-page .criterion-more { color: rgba(0, 0, 0, 0.45); }
.xray-page .routing-criteria-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
.light .xray-page .routing-criteria-empty { color: rgba(0, 0, 0, 0.3); }
/* Target cell (outbound / balancer) — vertically stacked rows of icon + pill */
.xray-page .routing-target-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.xray-page .routing-target-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-target-icon {
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
</style>
{{ template "page/body_end" .}}